diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 4dc4475735..1dafcd89d7 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -119,6 +119,13 @@ jobs: ./app_config.sh popd + - name: Build PIVX Sapling Lib + run: | + set -x -e + pushd scripts/android + ./build_pivx.sh + popd + - name: Install Flutter dependencies run: | flutter pub get diff --git a/.gitignore b/.gitignore index 2b437f06ed..b38dbc96f0 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,7 @@ lib/dogecoin/dogecoin.dart lib/base/base.dart lib/arbitrum/arbitrum.dart lib/evm/evm.dart +lib/pivx/pivx.dart lib/zcash/zcash.dart ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/assets/images/pivx_chain_qr.svg b/assets/images/pivx_chain_qr.svg new file mode 100644 index 0000000000..894412317c --- /dev/null +++ b/assets/images/pivx_chain_qr.svg @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/assets/images/pivx_icon.png b/assets/images/pivx_icon.png index 0b3b80eb13..fa70fa7370 100644 Binary files a/assets/images/pivx_icon.png and b/assets/images/pivx_icon.png differ diff --git a/assets/pivx_electrum_server_list.yml b/assets/pivx_electrum_server_list.yml new file mode 100644 index 0000000000..4047ab636e --- /dev/null +++ b/assets/pivx_electrum_server_list.yml @@ -0,0 +1,17 @@ +- + uri: electrum02.chainster.org:50002 + is_default: true + useSSL: true + isEnabledForAutoSwitching: true +- + uri: electrum01.chainster.org:50002 + useSSL: true + isEnabledForAutoSwitching: true +- + uri: electrum02.chainster.org:50001 + useSSL: false + isEnabledForAutoSwitching: true +- + uri: electrum01.chainster.org:50001 + useSSL: false + isEnabledForAutoSwitching: true diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index b3bc6e843d..d8f700afb4 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -106,12 +106,17 @@ class ElectrumClient { (Uint8List event) { try { final msg = utf8.decode(event.toList()); - final messagesList = msg.split("\n"); - for (var message in messagesList) { - if (message.isEmpty) { - continue; + // Accumulate across reads; large responses span multiple TCP packets. + unterminatedString += msg; + + while (unterminatedString.contains('\n')) { + final newlineIndex = unterminatedString.indexOf('\n'); + final completeLine = unterminatedString.substring(0, newlineIndex); + unterminatedString = unterminatedString.substring(newlineIndex + 1); + + if (completeLine.isNotEmpty) { + _parseResponse(completeLine); } - _parseResponse(message); } } catch (e) { printV("socket.listen: $e"); @@ -637,6 +642,15 @@ class ElectrumClient { id: 'blockchain.headers.subscribe', method: 'blockchain.headers.subscribe'); } + // PIVX Sapling 0-conf mempool push feed: initial snapshot then the same + // envelope on every change. + BehaviorSubject? saplingMempoolSubscribe() { + _id += 1; + return subscribe( + id: 'blockchain.sapling.mempool.subscribe', + method: 'blockchain.sapling.mempool.subscribe'); + } + BehaviorSubject? scripthashUpdate(String scripthash) { _id += 1; return subscribe( @@ -718,12 +732,35 @@ class ElectrumClient { } void _resetInternalStateCompletely() { + // unblock awaiting callers before clearing _tasks. an explicit close or + // reconnect that clears without onDone firing first would orphan an in-flight + // completer (e.g. node switching mid shield sync). + failPendingRequests(); _id = 0; _tasks.clear(); _errors.clear(); unterminatedString = ''; } + // fail in-flight request completers on disconnect so callers awaiting call() + // unblock instead of hanging. call() has no timeout, so a dropped/half-open + // socket leaves the completer dangling forever (this wedged pivx shield sync + // until restart). subscriptions are left alone so they resume on reconnect. + @visibleForTesting + void failPendingRequests() { + final pending = _tasks.entries + .where((task) => !task.value.isSubscription && task.value.completer != null) + .toList(); + for (final task in pending) { + final completer = task.value.completer!; + if (!completer.isCompleted) { + completer.completeError( + RequestFailedTimeoutException('connection_closed', 0)); + } + _tasks.remove(task.key); + } + } + void _registryTask(int id, Completer completer) => _tasks[id.toString()] = SocketTask(completer: completer, isSubscription: false); @@ -769,6 +806,10 @@ class ElectrumClient { final params = request['params'] as List; _tasks[_tasks.keys.first]?.subject?.add(params.last); break; + case 'blockchain.sapling.mempool.subscribe': + final params = request['params'] as List; + _tasks['blockchain.sapling.mempool.subscribe']?.subject?.add(params.last); + break; default: break; } @@ -782,6 +823,7 @@ class ElectrumClient { socket?.destroy(); } catch (_) {} socket = null; + failPendingRequests(); } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 8b1cc99fbb..f8a502f016 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -245,6 +245,8 @@ abstract class ElectrumWalletBase return bitcoinCashHDWallet(seedBytes); case CryptoCurrency.doge: return dogecoinHDWallet(seedBytes); + case CryptoCurrency.pivx: + return pivxHDWallet(seedBytes); default: throw Exception("Unsupported currency"); } @@ -260,6 +262,9 @@ abstract class ElectrumWalletBase static Bip32Slip10Secp256k1 dogecoinHDWallet(Uint8List seedBytes) => Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/3'/0'") as Bip32Slip10Secp256k1; + static Bip32Slip10Secp256k1 pivxHDWallet(Uint8List seedBytes) => + Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/119'/0'") as Bip32Slip10Secp256k1; + static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 68 + outputsCounts * 34 + 10; @@ -945,6 +950,12 @@ abstract class ElectrumWalletBase return utx.bitcoinAddressRecord.type == SegwitAddresType.mweb; case UnspentCoinType.nonMweb: return utx.bitcoinAddressRecord.type != SegwitAddresType.mweb; + case UnspentCoinType.sapling: + // PIVX shielded notes, not tracked as UTXOs + return false; + case UnspentCoinType.transparent: + // PIVX transparent, all regular UTXOs + return true; case UnspentCoinType.any: case UnspentCoinType.lightning: return true; @@ -1909,9 +1920,7 @@ abstract class ElectrumWalletBase } } - final results = shouldUseBatchFetching - ? await _fetchUnspentsBatch(targetAddresses) - : await _fetchUnspentsRegular(targetAddresses); + final results = await fetchUnspentsForAddresses(targetAddresses); final failedCount = results.where((result) => result == null).length; @@ -1944,6 +1953,18 @@ abstract class ElectrumWalletBase await _refreshUnspentCoinsInfo(); } + /// Fetch each address's unspents as a per-address list (null entry = fetch + /// failed for that address). Overridable so a coin with a custom scripthash or + /// confirmation source can batch its own way; the default picks batch vs + /// regular fetching. + Future?>> fetchUnspentsForAddresses( + List addresses, + ) async { + return shouldUseBatchFetching + ? await _fetchUnspentsBatch(addresses) + : await _fetchUnspentsRegular(addresses); + } + Future?>> _fetchUnspentsRegular( List addresses, ) async { @@ -2606,6 +2627,9 @@ abstract class ElectrumWalletBase await Future.wait(DOGECOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) : fetchTransactionsForAddressType(historiesWithDetails, type))); + } else if (type == WalletType.pivx) { + await Future.wait(PIVX_ADDRESS_TYPES + .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); } transactionHistory.transactions.values.forEach((tx) async { diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index 0beb02d253..e8544d2980 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -43,6 +43,10 @@ const List DOGECOIN_ADDRESS_TYPES = [ P2pkhAddressType.p2pkh, ]; +const List PIVX_ADDRESS_TYPES = [ + P2pkhAddressType.p2pkh, +]; + abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { ElectrumWalletAddressesBase( WalletInfo walletInfo, { @@ -365,6 +369,8 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } } else if (walletInfo.type == WalletType.dogecoin) { await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); + } else if (walletInfo.type == WalletType.pivx) { + await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); } else if (walletInfo.type == WalletType.bitcoin) { await _generateInitialAddresses(isLegacyDerivation: true); await _generateInitialAddresses(); @@ -385,9 +391,12 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } updateAddressesByMatch(); + // reconcile branch/derivation before building the receive/change lists + // (they partition on isHidden), so a corrected isHidden lands in the right + // list. + await _validateAddresses(); updateReceiveAddresses(); updateChangeAddresses(); - _validateAddresses(); await updateAddressesInBox(); if (currentReceiveAddressIndex >= receiveAddresses.length) { @@ -624,6 +633,9 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { case WalletType.dogecoin: addP2PKHAddressTypes(); break; + case WalletType.pivx: + addP2PKHAddressTypes(); + break; default: break; } @@ -869,13 +881,20 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { updateAddressesByMatch(); } - void _validateAddresses() { - _addresses.forEach((element) async { + Future _validateAddresses() async { + await Future.wait(_addresses.map((element) async { if (element.type == SegwitAddresType.mweb) { // this would add a ton of startup lag for mweb addresses since we have 1000 of them return; } + // pivx re-derives against branch and derivation combos; other coins keep + // the base flip so their behavior is unchanged. + if (walletInfo.type == WalletType.pivx) { + await _reconcileAddressMetadata(element); + return; + } + final mainHd = _hdFor( isHidden: false, type: element.type, isLegacyDerivation: element.isLegacyDerivation); final sideHd = _hdFor( @@ -889,7 +908,36 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { await getAddressAsync(index: element.index, hd: sideHd, addressType: element.type)) { element.isHidden = false; } - }); + })); + } + + /// Set a record's branch (isHidden) and derivation (isLegacyDerivation) to a + /// combo that re-derives its stored address. The base flip changed isHidden on + /// any mismatch without checking the target re-derives, leaving a still-wrong + /// record whose key can't sign its UTXO. Try the stored combo first, then + /// alternatives, apply only one that verifies. index is final, so a record + /// matching none is left for the signer to reject. + Future _reconcileAddressMetadata(BitcoinAddressRecord element) async { + for (final isLegacyDerivation in [ + element.isLegacyDerivation, + !element.isLegacyDerivation, + ]) { + for (final isHidden in [element.isHidden, !element.isHidden]) { + final hd = _hdFor( + isHidden: isHidden, + type: element.type, + isLegacyDerivation: isLegacyDerivation); + final derived = await getAddressAsync( + index: element.index, hd: hd, addressType: element.type); + if (element.address == derived) { + if (element.isHidden != isHidden) element.isHidden = isHidden; + if (element.isLegacyDerivation != isLegacyDerivation) { + element.isLegacyDerivation = isLegacyDerivation; + } + return; + } + } + } } @override diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index 922a83f1de..1d03afac73 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -92,8 +92,8 @@ dependency_overrides: protobuf: ^3.1.0 bitcoin_base: git: - url: https://github.com/cake-tech/bitcoin_base - ref: 4e41f96f4838139895c65c3f49109d05af5d46aa + url: https://github.com/Liquid369/bitcoin_base + ref: 490c1b644090685a49b2952b2b1205f0b4c50e84 pointycastle: 3.7.4 ffi: 2.1.0 intl: any diff --git a/cw_bitcoin/test/electrum_client_disconnect_test.dart b/cw_bitcoin/test/electrum_client_disconnect_test.dart new file mode 100644 index 0000000000..0df11b7c9f --- /dev/null +++ b/cw_bitcoin/test/electrum_client_disconnect_test.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:cw_bitcoin/electrum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rxdart/rxdart.dart'; + +void main() { + // Regression: a dropped/half-open socket must not leave an in-flight `call` + // hanging forever. That dangling completer previously wedged PIVX shielded + // sync — the sync task never returned, its in-progress guard stayed set, and + // no further sync ran until the app was restarted with a fresh client. + test('failPendingRequests errors in-flight calls and keeps subscriptions', () { + final client = ElectrumClient(); + + final request = Completer(); + final subscription = BehaviorSubject(); + client.tasks['1'] = SocketTask(completer: request, isSubscription: false); + client.tasks['blockchain.headers.subscribe'] = + SocketTask(subject: subscription, isSubscription: true); + + // Swallow the delivered error so it isn't an unhandled async error. + final requestFuture = request.future.catchError((Object _) => null); + + client.failPendingRequests(); + + expect(request.isCompleted, isTrue); + // In-flight request is dropped from the registry, subscription survives. + expect(client.tasks.containsKey('1'), isFalse); + expect(client.tasks.containsKey('blockchain.headers.subscribe'), isTrue); + + return requestFuture; // completes (with the swallowed error) → no hang + }); + + test('failPendingRequests is safe with no pending tasks', () { + final client = ElectrumClient(); + expect(client.failPendingRequests, returnsNormally); + }); + + // Wiring: an explicit close() (node switching / reconnect teardown) must + // unblock in-flight requests even if the socket never fires onDone — otherwise + // _tasks.clear() orphans the completer and the awaiting caller hangs forever. + test('close() fails pending in-flight requests before clearing tasks', + () async { + final client = ElectrumClient(); + final request = Completer(); + client.tasks['7'] = SocketTask(completer: request, isSubscription: false); + + final requestFuture = request.future.catchError((Object _) => null); + await client.close(); + + expect(request.isCompleted, isTrue); + expect(client.tasks.containsKey('7'), isFalse); + await requestFuture; // resolves (with swallowed error) → proves no hang + }); +} diff --git a/cw_bitcoin_cash/pubspec.yaml b/cw_bitcoin_cash/pubspec.yaml index 77956e97ef..cde0b32b67 100644 --- a/cw_bitcoin_cash/pubspec.yaml +++ b/cw_bitcoin_cash/pubspec.yaml @@ -42,8 +42,8 @@ dependency_overrides: watcher: ^1.1.0 bitcoin_base: git: - url: https://github.com/cake-tech/bitcoin_base - ref: 4e41f96f4838139895c65c3f49109d05af5d46aa + url: https://github.com/Liquid369/bitcoin_base + ref: 490c1b644090685a49b2952b2b1205f0b4c50e84 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/cw_core/lib/crypto_currency.dart b/cw_core/lib/crypto_currency.dart index 4954cad279..02f8648c4f 100644 --- a/cw_core/lib/crypto_currency.dart +++ b/cw_core/lib/crypto_currency.dart @@ -534,7 +534,12 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen iconPath: 'assets/new-ui/crypto_full_icons/paxg.svg', decimals: 18); static const pivx = CryptoCurrency( - title: 'PIVX', raw: 56, name: 'pivx', iconPath: 'assets/images/pivx_icon.png', decimals: 8); + title: 'PIVX', + raw: 56, + name: 'pivx', + iconPath: 'assets/images/pivx_icon.png', + flatIconPath: 'assets/images/pivx_icon.png', + decimals: 8); static const rune = CryptoCurrency( title: 'RUNE', fullName: 'Thorchain', diff --git a/cw_core/lib/currency_for_wallet_type.dart b/cw_core/lib/currency_for_wallet_type.dart index c31aa5b266..394eb7c068 100644 --- a/cw_core/lib/currency_for_wallet_type.dart +++ b/cw_core/lib/currency_for_wallet_type.dart @@ -48,6 +48,8 @@ CryptoCurrency walletTypeToCryptoCurrency(WalletType type, {bool isTestnet = fal return CryptoCurrency.doge; case WalletType.zcash: return CryptoCurrency.zec; + case WalletType.pivx: + return CryptoCurrency.pivx; case WalletType.none: throw Exception( 'Unexpected wallet type: ${type.toString()} for CryptoCurrency walletTypeToCryptoCurrency'); @@ -150,6 +152,7 @@ String? symbolIconPathForWalletType(WalletType type) { case WalletType.wownero: case WalletType.haven: case WalletType.banano: + case WalletType.pivx: case WalletType.none: return null; } diff --git a/cw_core/lib/node.dart b/cw_core/lib/node.dart index 78faba1426..7a2fcebe67 100644 --- a/cw_core/lib/node.dart +++ b/cw_core/lib/node.dart @@ -55,6 +55,13 @@ class Node { this.isOfficial = false, this.isBuiltin = false, this.isDefault = false, + this.supportsPivxSapling, + this.pivxSaplingContract, + this.pivxSaplingServerVersion, + this.pivxCoreVersion, + this.pivxSaplingNetwork, + this.pivxSaplingActivationHeight, + this.pivxSaplingLastCheckedAt, String? uri, WalletType? type, }) { @@ -98,7 +105,14 @@ class Node { isEnabledForAutoSwitching = _getBoolFromDB(map['isEnabledForAutoSwitching']), isOfficial = _getBoolFromDB(map['isOfficial']), isBuiltin = _getBoolFromDB(map['isBuiltin']), - isDefault = _getBoolFromDB(map['isDefault']); + isDefault = _getBoolFromDB(map['isDefault']), + supportsPivxSapling = map['supportsPivxSapling'] as bool?, + pivxSaplingContract = map['pivxSaplingContract'] as String?, + pivxSaplingServerVersion = map['pivxSaplingServerVersion'] as String?, + pivxCoreVersion = map['pivxCoreVersion'] as String?, + pivxSaplingNetwork = map['pivxSaplingNetwork'] as String?, + pivxSaplingActivationHeight = map['pivxSaplingActivationHeight'] as int?, + pivxSaplingLastCheckedAt = map['pivxSaplingLastCheckedAt'] as DateTime?; static bool _getBoolFromDB(value, {bool? defaultValue}) { if (value is bool) { @@ -261,6 +275,30 @@ class Node { static String get tableName => "Node"; static String get selfIdColumn => "${tableName}Id"; + bool? supportsPivxSapling; + + String? pivxSaplingContract; + + String? pivxSaplingServerVersion; + + String? pivxCoreVersion; + + String? pivxSaplingNetwork; + + int? pivxSaplingActivationHeight; + + DateTime? pivxSaplingLastCheckedAt; + + String get pivxSaplingVersionLabel { + final parts = [ + if (pivxSaplingContract?.isNotEmpty ?? false) pivxSaplingContract!, + if (pivxSaplingServerVersion?.isNotEmpty ?? false) pivxSaplingServerVersion!, + if (pivxCoreVersion?.isNotEmpty ?? false) 'Core $pivxCoreVersion', + ]; + + return parts.isEmpty ? 'PIVX Sapling' : parts.join(' / '); + } + bool get isSSL => useSSL ?? false; bool get useSocksProxy => socksProxyAddress == null ? false : socksProxyAddress!.isNotEmpty; @@ -285,6 +323,7 @@ class Node { case WalletType.litecoin: case WalletType.bitcoinCash: case WalletType.dogecoin: + case WalletType.pivx: return createUriFromElectrumAddress(uriRaw, path!); case WalletType.nano: case WalletType.banano: @@ -357,6 +396,7 @@ class Node { case WalletType.tron: case WalletType.dogecoin: case WalletType.zcash: + case WalletType.pivx: return requestElectrumServer(); case WalletType.zano: return requestZanoNode(); diff --git a/cw_core/lib/node_legacy.dart b/cw_core/lib/node_legacy.dart index 848b771f5e..639e2fc220 100644 --- a/cw_core/lib/node_legacy.dart +++ b/cw_core/lib/node_legacy.dart @@ -176,6 +176,7 @@ class Node extends HiveObject with Keyable { case WalletType.litecoin: case WalletType.bitcoinCash: case WalletType.dogecoin: + case WalletType.pivx: return createUriFromElectrumAddress(uriRaw, path!); case WalletType.nano: case WalletType.banano: diff --git a/cw_core/lib/node_list.dart b/cw_core/lib/node_list.dart index 899d1ed980..0bc6e1d49d 100644 --- a/cw_core/lib/node_list.dart +++ b/cw_core/lib/node_list.dart @@ -49,6 +49,9 @@ Future> loadDefaultNodes(WalletType type) async { case WalletType.dogecoin: path = 'assets/dogecoin_electrum_server_list.yml'; break; + case WalletType.pivx: + path = 'assets/pivx_electrum_server_list.yml'; + break; case WalletType.base: path = 'assets/base_node_list.yml'; break; diff --git a/cw_core/lib/payment_uris.dart b/cw_core/lib/payment_uris.dart index effeb13a85..04122f40ee 100644 --- a/cw_core/lib/payment_uris.dart +++ b/cw_core/lib/payment_uris.dart @@ -248,6 +248,21 @@ class ZcashURI extends PaymentURI { } } +class PivxURI extends PaymentURI { + PivxURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'pivx:$address'; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + class ERC681URI extends PaymentURI { ERC681URI({ required this.chainId, diff --git a/cw_core/lib/unspent_coin_type.dart b/cw_core/lib/unspent_coin_type.dart index 859457c498..18c25270a9 100644 --- a/cw_core/lib/unspent_coin_type.dart +++ b/cw_core/lib/unspent_coin_type.dart @@ -1 +1,19 @@ -enum UnspentCoinType { mweb, nonMweb, any, lightning } +/// Types of unspent outputs for coins with multiple pools. +/// +/// Used by: +/// - Litecoin: mweb (MWEB shielded), nonMweb (transparent), any +/// - PIVX: sapling (Sapling shielded), transparent, any +enum UnspentCoinType { + /// MWEB shielded outputs (Litecoin) + mweb, + /// Non-MWEB outputs (Litecoin transparent) + nonMweb, + /// Any type of output + any, + /// Lightning outputs + lightning, + /// Sapling shielded notes (PIVX) + sapling, + /// Transparent UTXOs (PIVX) + transparent +} diff --git a/cw_core/lib/wallet_type.dart b/cw_core/lib/wallet_type.dart index 5ccfbbc23a..252afb6e35 100644 --- a/cw_core/lib/wallet_type.dart +++ b/cw_core/lib/wallet_type.dart @@ -24,6 +24,7 @@ const walletTypes = [ WalletType.arbitrum, WalletType.zcash, WalletType.bsc, + WalletType.pivx, ]; const electrumWalletTypes = [ @@ -102,6 +103,9 @@ enum WalletType { // @HiveField(19) bsc, + + // @HiveField(20) + pivx, } int serializeToInt(WalletType type) { @@ -144,6 +148,8 @@ int serializeToInt(WalletType type) { return 17; case WalletType.bsc: return 18; + case WalletType.pivx: + return 19; case WalletType.none: return -1; } @@ -189,6 +195,8 @@ WalletType deserializeFromInt(int raw) { return WalletType.zcash; case 18: return WalletType.bsc; + case 19: + return WalletType.pivx; default: throw Exception('Unexpected token: $raw for WalletType deserializeFromInt'); } @@ -234,6 +242,8 @@ String walletTypeToString(WalletType type) { return 'Zcash'; case WalletType.bsc: return 'BNB Smart Chain'; + case WalletType.pivx: + return 'PIVX'; case WalletType.none: return ''; } @@ -259,6 +269,7 @@ String walletTypeToDisplayName(WalletType type) => switch (type) { WalletType.arbitrum => 'Arbitrum', WalletType.zcash => 'Zcash', WalletType.bsc => 'BNB Smart Chain', + WalletType.pivx => 'PIVX', WalletType.none => '' }; @@ -282,6 +293,7 @@ String walletTypeToDisplayTicker(WalletType type) => switch (type) { WalletType.arbitrum => 'ARB', WalletType.zcash => 'ZEC', WalletType.bsc => 'BNB', + WalletType.pivx => 'PIVX', WalletType.none => '' }; @@ -327,6 +339,8 @@ WalletType? _cryptoCurrencyToWalletType(CryptoCurrency type) { return WalletType.dogecoin; case CryptoCurrency.zec: return WalletType.zcash; + case CryptoCurrency.pivx: + return WalletType.pivx; default: return null; } diff --git a/cw_core/lib/wallet_type.part.dart b/cw_core/lib/wallet_type.part.dart index 2451e1cfa0..8c0a4a34d8 100644 --- a/cw_core/lib/wallet_type.part.dart +++ b/cw_core/lib/wallet_type.part.dart @@ -53,6 +53,8 @@ class WalletTypeAdapter extends TypeAdapter { return WalletType.zcash; case 19: return WalletType.bsc; + case 20: + return WalletType.pivx; default: return WalletType.monero; } @@ -121,6 +123,9 @@ class WalletTypeAdapter extends TypeAdapter { case WalletType.bsc: writer.writeByte(19); break; + case WalletType.pivx: + writer.writeByte(20); + break; } } diff --git a/cw_dogecoin/pubspec.yaml b/cw_dogecoin/pubspec.yaml index 146c7abd01..104a52a1c3 100644 --- a/cw_dogecoin/pubspec.yaml +++ b/cw_dogecoin/pubspec.yaml @@ -39,8 +39,8 @@ dependency_overrides: watcher: ^1.1.0 bitcoin_base: git: - url: https://github.com/cake-tech/bitcoin_base - ref: 4e41f96f4838139895c65c3f49109d05af5d46aa + url: https://github.com/Liquid369/bitcoin_base + ref: 490c1b644090685a49b2952b2b1205f0b4c50e84 # For information on the generic Dart part of this file, see the diff --git a/cw_pivx/.gitignore b/cw_pivx/.gitignore new file mode 100644 index 0000000000..94aae0cbfe --- /dev/null +++ b/cw_pivx/.gitignore @@ -0,0 +1,38 @@ +# Dart build artifacts +.dart_tool/ +.packages +build/ +*.g.dart + +# Flutter +.flutter-plugins +.flutter-plugins-dependencies + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store + +# Generated code +*.freezed.dart +*.mocks.dart + +# Test +coverage/ + +# Sapling proving params — ~51MB, fetched via scripts/prepare_pivx_params.sh, +# bundled as an app asset, not committed. +assets/params/*.params + +# Native Sapling libraries — built from source via scripts/android/build_pivx.sh +# and the sibling scripts/{ios,macos,linux}/build_pivx.sh, not committed. +android/src/main/jniLibs/ +macos/Frameworks/*.dylib +macos/Frameworks/*.a +ios/Frameworks/*.dylib +ios/Frameworks/*.a +linux/lib/*.so diff --git a/cw_pivx/CHANGELOG.md b/cw_pivx/CHANGELOG.md new file mode 100644 index 0000000000..fcb8144414 --- /dev/null +++ b/cw_pivx/CHANGELOG.md @@ -0,0 +1,7 @@ +## 0.0.1 + +* Initial PIVX wallet integration +* BIP39/BIP44 HD wallet support (coin type 119) +* P2PKH address generation +* ElectrumX backend integration +* Transaction building and signing diff --git a/cw_pivx/LICENSE b/cw_pivx/LICENSE new file mode 100644 index 0000000000..7e504ace76 --- /dev/null +++ b/cw_pivx/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Cake Labs LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cw_pivx/README.md b/cw_pivx/README.md new file mode 100644 index 0000000000..93d59331f1 --- /dev/null +++ b/cw_pivx/README.md @@ -0,0 +1,39 @@ +# PIVX Wallet Integration for Cake Wallet + +This package provides PIVX wallet functionality for Cake Wallet. + +## Features + +- BIP39/BIP44 HD wallet with PIVX coin type 119 +- P2PKH address generation (addresses starting with 'D') +- ElectrumX backend integration +- Transaction creation and signing +- Balance tracking +- Sapling shielded transactions (addresses starting with 'ps'): send, receive, and encrypted memos, with native Rust proving/scanning + +## PIVX-Specific Details + +### Network Parameters (from PIVX Core) + +- **Coin Type (SLIP-44):** 119 +- **Derivation Path:** m/44'/119'/account'/change/index +- **P2PKH Prefix:** 30 (addresses start with 'D') +- **P2SH Prefix:** 13 (addresses start with '6') +- **Staking Prefix:** 63 (addresses start with 'S') +- **WIF Prefix:** 212 +- **P2P Port:** 51472 +- **RPC Port:** 51473 +- **Block Time:** 60 seconds +- **Coinbase Maturity:** 100 blocks + +### Transactions + +- Transparent: standard P2PKH sends and receives. +- Shielded (Sapling): send, receive, and encrypted memos across every t/z combination (t to t, t to z, z to t, z to z). + +Coinbase and coinstake outputs (block and stake rewards) are recognized while scanning history but are not created by the wallet. + +## References + +- [PIVX Core](https://github.com/PIVX-Project/PIVX) +- [SLIP-0044](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) diff --git a/cw_pivx/analysis_options.yaml b/cw_pivx/analysis_options.yaml new file mode 100644 index 0000000000..12e713abf9 --- /dev/null +++ b/cw_pivx/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:lints/recommended.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/cw_pivx/android/build.gradle b/cw_pivx/android/build.gradle new file mode 100644 index 0000000000..f7b861eb63 --- /dev/null +++ b/cw_pivx/android/build.gradle @@ -0,0 +1,66 @@ +group 'com.cakewallet.cw_pivx' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'com.cakewallet.cw_pivx' + compileSdkVersion 33 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 21 + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86' + } + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" +} diff --git a/cw_pivx/android/settings.gradle b/cw_pivx/android/settings.gradle new file mode 100644 index 0000000000..a25e31197c --- /dev/null +++ b/cw_pivx/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'cw_pivx' diff --git a/cw_pivx/android/src/main/AndroidManifest.xml b/cw_pivx/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7804ff8a86 --- /dev/null +++ b/cw_pivx/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/cw_pivx/android/src/main/kotlin/com/cakewallet/cw_pivx/CwPivxPlugin.kt b/cw_pivx/android/src/main/kotlin/com/cakewallet/cw_pivx/CwPivxPlugin.kt new file mode 100644 index 0000000000..45e636ee47 --- /dev/null +++ b/cw_pivx/android/src/main/kotlin/com/cakewallet/cw_pivx/CwPivxPlugin.kt @@ -0,0 +1,62 @@ +package com.cakewallet.cw_pivx + +import androidx.annotation.NonNull + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** CwPivxPlugin */ +class CwPivxPlugin: FlutterPlugin, MethodCallHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private lateinit var channel : MethodChannel + private var nativeLibraryLoaded = false + private var nativeLibraryError: String? = null + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "cw_pivx") + channel.setMethodCallHandler(this) + + loadSaplingNativeLibrary() + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "isSaplingNativeLoaded" -> { + result.success(nativeLibraryLoaded) + } + "getSaplingNativeLoadError" -> { + result.success(nativeLibraryError) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + private fun loadSaplingNativeLibrary() { + try { + System.loadLibrary("cw_pivx_sapling") + nativeLibraryLoaded = true + nativeLibraryError = null + } catch (error: UnsatisfiedLinkError) { + nativeLibraryLoaded = false + nativeLibraryError = error.message ?: error.javaClass.simpleName + } catch (error: SecurityException) { + nativeLibraryLoaded = false + nativeLibraryError = error.message ?: error.javaClass.simpleName + } + } +} diff --git a/cw_pivx/ios/.gitignore b/cw_pivx/ios/.gitignore new file mode 100644 index 0000000000..7fea5d4e2f --- /dev/null +++ b/cw_pivx/ios/.gitignore @@ -0,0 +1,22 @@ +# Ignore build artifacts +Frameworks/*.xcframework/ +Frameworks/*.a +*.dSYM/ + +# Keep the header file +!Classes/cw_pivx_sapling.h + +# Xcode +xcuserdata/ +*.xccheckout +build/ +DerivedData/ +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +*.xcworkspace + +# Cocoapods +Pods/ +Podfile.lock diff --git a/cw_pivx/ios/Classes/CwPivxPlugin.swift b/cw_pivx/ios/Classes/CwPivxPlugin.swift new file mode 100644 index 0000000000..d609a9cc0d --- /dev/null +++ b/cw_pivx/ios/Classes/CwPivxPlugin.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +public class CwPivxPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "cw_pivx", binaryMessenger: registrar.messenger()) + let instance = CwPivxPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/cw_pivx/ios/Classes/cw_pivx_sapling.h b/cw_pivx/ios/Classes/cw_pivx_sapling.h new file mode 100644 index 0000000000..a3a0619e75 --- /dev/null +++ b/cw_pivx/ios/Classes/cw_pivx_sapling.h @@ -0,0 +1,181 @@ +/* PIVX Sapling FFI - Auto-generated by cbindgen */ + +#ifndef CW_PIVX_SAPLING_H +#define CW_PIVX_SAPLING_H + +/* Generated with cbindgen:0.29.0 */ + +/* Warning: this file was auto-generated by cbindgen. Don't modify this manually. */ + +#include +#include +#include +#include + +/** + * Sapling tree depth constant. + */ +#define SAPLING_TREE_DEPTH 32 + +/** + * PIVX Sapling activation height. + */ +#define PIVX_SAPLING_ACTIVATION 2700500 + +/** + * FFI buffer for returning binary data. + */ +typedef struct FFIBuffer { + uint8_t *data; + uintptr_t len; +} FFIBuffer; + +/** + * Derive an address at a specific index. + */ +char *cw_pivx_derive_address(int64_t handle, + uint64_t index); + +/** + * Dispose keys. + */ +void cw_pivx_dispose_keys(int64_t handle); + +/** + * Dispose sync engine. + */ +void cw_pivx_dispose_sync_engine(int64_t handle); + +/** + * Estimate transaction fee. + */ +uint64_t cw_pivx_estimate_fee(uintptr_t spends, + uintptr_t outputs, + uintptr_t t_inputs, + uintptr_t t_outputs); + +/** + * Free a buffer allocated by this library. + */ +void cw_pivx_free_buffer(struct FFIBuffer buffer); + +/** + * Free a string allocated by this library. + */ +void cw_pivx_free_string(char *ptr); + +/** + * Get the default payment address. + */ +char *cw_pivx_get_default_address(int64_t handle); + +/** + * Get and clear the last error message. + */ +char *cw_pivx_get_last_error(void); + +/** + * Get the shielded balance. + */ +uint64_t cw_pivx_get_shielded_balance(int64_t handle); + +/** + * Get the current sync height. + */ +uint32_t cw_pivx_get_sync_height(int64_t handle); + +/** + * Get the number of unspent notes. + */ +uintptr_t cw_pivx_get_unspent_note_count(int64_t handle); + +/** + * Get the full viewing key. + */ +char *cw_pivx_get_viewing_key(int64_t handle); + +/** + * Check if proving parameters are available. + */ +uint8_t cw_pivx_has_proving_params(const char *path); + +/** + * Initialize keys from a seed. + * Returns a handle for future operations, or -1 on error. + */ +int64_t cw_pivx_init_keys(const uint8_t *seed, + uintptr_t seed_len, + uint8_t is_testnet); + +/** + * Initialize sync engine. + */ +int64_t cw_pivx_init_sync_engine(uint8_t _is_testnet); + +/** + * Reset sync state. + */ +void cw_pivx_reset_sync(int64_t handle); + +/** + * Validate a Sapling address. + */ +uint8_t cw_pivx_validate_address(const char *address, + uint8_t is_testnet); + +/** + * Get the library version. + */ +char *cw_pivx_version(void); + +/** + * Clear the last error. + */ +void pivx_clear_last_error(void); + +/** + * Free a byte buffer allocated by this library. + * + * # Safety + * The pointer must have been allocated by this library and not already freed. + */ +void pivx_free_buffer(unsigned char *ptr, + uintptr_t len); + +/** + * Free a string allocated by this library. + * + * # Safety + * The pointer must have been allocated by this library and not already freed. + */ +void pivx_free_string(char *s); + +/** + * Get the last error message. + * Returns null if no error occurred. + * Caller must free the returned string with `pivx_free_string`. + */ +char *pivx_get_last_error(void); + +int32_t pivx_sapling_create_from_seed(const uint8_t *seed, + uintptr_t seed_len, + int32_t is_testnet, + int32_t *session_id); + +int32_t pivx_sapling_destroy(int32_t session_id); + +void pivx_sapling_free_string(char *ptr); + +int64_t pivx_sapling_get_balance(int32_t session_id); + +int32_t pivx_sapling_get_sync_height(int32_t session_id); + +int32_t pivx_sapling_init(void); + +/** + * Get the library version string. + * Caller must free the returned string with `pivx_free_string`. + */ +char *pivx_sapling_version(void); + +#endif /* CW_PIVX_SAPLING_H */ diff --git a/cw_pivx/ios/Frameworks/README.md b/cw_pivx/ios/Frameworks/README.md new file mode 100644 index 0000000000..79d82cea2f --- /dev/null +++ b/cw_pivx/ios/Frameworks/README.md @@ -0,0 +1,31 @@ +# PIVX Sapling XCFramework + +This directory contains the native PIVX Sapling library as an XCFramework. + +## Building + +Run the build script from the cw_pivx directory: + +```bash +./scripts/build_ios.sh +``` + +This will: +1. Build the Rust library for iOS device (arm64) +2. Build for iOS simulator (arm64, x86_64) +3. Create a universal XCFramework +4. Generate the C header + +## Contents + +After building: +- `cw_pivx_sapling.xcframework/` - Universal framework for iOS device and simulators + - `ios-arm64/` - Device slice + - `ios-arm64_x86_64-simulator/` - Simulator slice + +## Requirements + +- Rust (with cargo) +- rustup targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` +- Xcode Command Line Tools (for lipo, xcodebuild) +- cbindgen (`cargo install cbindgen`) diff --git a/cw_pivx/ios/Frameworks/cw_pivx_sapling.h b/cw_pivx/ios/Frameworks/cw_pivx_sapling.h new file mode 100644 index 0000000000..121a67ffaa --- /dev/null +++ b/cw_pivx/ios/Frameworks/cw_pivx_sapling.h @@ -0,0 +1,359 @@ +/* PIVX Sapling FFI - Auto-generated by cbindgen */ + +#ifndef CW_PIVX_SAPLING_H +#define CW_PIVX_SAPLING_H + +/* Generated with cbindgen:0.29.0 */ + +/* Warning: this file was auto-generated by cbindgen. Don't modify this manually. */ + +#include +#include +#include +#include + +#define SAPLING_TREE_DEPTH 32 + +/** + * PIVX max supply: 21,000,000 coins = 21,000,000,000,000 zatoshis (21 trillion zatoshis). + */ +#define PIVX_MAX_SUPPLY 21000000000000ull + +/** + * Shielded dust threshold derived from PIVX Core v5.6.1: + * 100 * dustRelayFee.GetFee(384-byte spend + 34-byte txout + 64-byte binding sig). + */ +#define SHIELDED_DUST_THRESHOLD 1446000ull + +/** + * Transparent dust threshold derived from PIVX Core v5.6.1: + * dustRelayFee.GetFee(182) with dust relay fee 30,000 zatoshis/kB. + */ +#define TRANSPARENT_DUST_THRESHOLD 5460ull + +/** + * PIVX Sapling activation height. + */ +#define PIVX_SAPLING_ACTIVATION 2700500 + +#define PIVX_TESTNET_SAPLING_ACTIVATION 201 + +/** + * Default Bitcoin/PIVX sequence number. + */ +#define TransparentInput_SEQUENCE_FINAL 4294967295 + +/** + * FFI buffer for returning binary data. + */ +typedef struct FFIBuffer { + uint8_t *data; + uintptr_t len; +} FFIBuffer; + +/** + * Build a transparent-to-shielded (t-to-z, shield) transaction. + * + * `utxos_json` is an array of objects with `txid` (display hex), `vout`, + * `value`, `script_pubkey` (hex, P2PKH) and `private_key` (32-byte hex). + * `change` of zero means no transparent change output; otherwise + * `change_address` receives it. Amounts must balance exactly: + * sum(utxos) = amount + change + fee. + */ +struct FFIBuffer cw_pivx_build_shield_tx(int64_t key_handle, + const char *utxos_json, + const char *to_address, + uint64_t amount, + const char *memo, + uint64_t fee, + const char *change_address, + uint64_t change); + +struct FFIBuffer cw_pivx_build_shielded_tx(int64_t key_handle, + const char *notes_json, + const char *to_address, + uint64_t amount, + const char *memo, + uint64_t fee, + const char *anchor_hex); + +/** + * Check if a nullifier matches any of our notes and mark them spent. + * + * # Parameters + * * `sync_handle` - Handle from cw_pivx_init_sync_engine + * * `nullifier` - 32-byte nullifier to check + * + * # Returns + * 1 if a note was marked spent, 0 otherwise. + */ +uint8_t cw_pivx_check_nullifier(int64_t sync_handle, + const uint8_t *nullifier); + +/** + * Derive an address at a specific index. + */ +char *cw_pivx_derive_address(int64_t handle, + uint64_t index); + +/** + * Dispose keys. + */ +void cw_pivx_dispose_keys(int64_t handle); + +/** + * Free the prover and release memory (~50MB). + */ +void cw_pivx_dispose_prover(void); + +/** + * Dispose sync engine. + */ +void cw_pivx_dispose_sync_engine(int64_t handle); + +/** + * Estimate transaction fee. + * Returns the estimated fee in zatoshis, or u64::MAX if overflow would occur. + */ +uint64_t cw_pivx_estimate_fee(uintptr_t spends, + uintptr_t outputs, + uintptr_t t_inputs, + uintptr_t t_outputs); + +/** + * Free a buffer allocated by this library. + */ +void cw_pivx_free_buffer(struct FFIBuffer buffer); + +/** + * Free a string allocated by this library. + */ +void cw_pivx_free_string(char *ptr); + +/** + * Get the default payment address. + */ +char *cw_pivx_get_default_address(int64_t handle); + +/** + * Get and clear the last error message. + */ +char *cw_pivx_get_last_error(void); + +/** + * Get the single unspent note at [position] as JSON (the one just decrypted), + * so the scan loop doesn't re-serialize every note on each match (was O(K^2) + * over a restore). Returns null if there's no unspent note there. + * Caller must free with cw_pivx_free_string. + */ +char *cw_pivx_get_note_at_position(int64_t sync_handle, + uint64_t position); + +/** + * Get the shielded balance. + */ +uint64_t cw_pivx_get_shielded_balance(int64_t handle); + +/** + * Get all spendable notes from the sync state as JSON. + * + * Returns a JSON array of note objects, each containing all data + * needed for transaction building including the rseed and diversifier. + * + * # Parameters + * * `sync_handle` - Handle from cw_pivx_init_sync_engine + * + * # Returns + * JSON string with note data, or null on error. + * Caller must free with cw_pivx_free_string. + */ +char *cw_pivx_get_spendable_notes(int64_t sync_handle); + +/** + * Get the current sync height. + */ +uint32_t cw_pivx_get_sync_height(int64_t handle); + +/** + * Get the number of unspent notes. + */ +uintptr_t cw_pivx_get_unspent_note_count(int64_t handle); + +/** + * Get the full viewing key. + */ +char *cw_pivx_get_viewing_key(int64_t handle); + +/** + * Check if proving parameters are available. + */ +uint8_t cw_pivx_has_proving_params(const char *path); + +/** + * Initialize keys from a seed. + * Returns a handle for future operations, or -1 on error. + */ +int64_t cw_pivx_init_keys(const uint8_t *seed, + uintptr_t seed_len, + uint8_t is_testnet); + +/** + * Initialize the Groth16 prover with the proving parameters. + * + * This loads the ~50MB proving parameter files into memory. + * Should be called once before any transaction building. + * + * # Parameters + * * `params_dir` - Path to directory containing sapling-spend.params and sapling-output.params + * + * # Returns + * 0 on success, negative on error + */ +int32_t cw_pivx_init_prover(const char *params_dir); + +/** + * Initialize sync engine. + */ +int64_t cw_pivx_init_sync_engine(uint8_t _is_testnet); + +/** + * Check if the prover is initialized. + */ +uint8_t cw_pivx_is_prover_initialized(void); + +/** + * Reset sync state. + */ +void cw_pivx_reset_sync(int64_t handle); + +/** + * Restore a note from JSON data. + * + * This allows restoring notes from persistent storage after app restart. + * The JSON should contain the same fields returned by cw_pivx_get_spendable_notes. + * + * # Parameters + * * `key_handle` - Handle from cw_pivx_init_keys + * * `sync_handle` - Handle from cw_pivx_init_sync_engine + * * `note_json` - JSON string with note data + * + * # Returns + * 1 on success, 0 on failure + */ +int32_t cw_pivx_restore_note(int64_t _key_handle, + int64_t sync_handle, + const char *note_json); + +/** + * Update sync height after processing a block. + */ +void cw_pivx_set_sync_height(int64_t sync_handle, + uint32_t height); + +/** + * Try to decrypt a Sapling output and add to sync state if successful. + * + * This is the core function for detecting incoming shielded transactions. + * It attempts trial decryption of a Sapling output using the wallet's + * incoming viewing key. + * + * # Parameters + * * `key_handle` - Handle from cw_pivx_init_keys + * * `sync_handle` - Handle from cw_pivx_init_sync_engine + * * `cmu` - Note commitment (32 bytes) + * * `epk` - Ephemeral public key (32 bytes) + * * `enc_ciphertext` - Encrypted ciphertext (580 bytes) + * * `height` - Block height + * * `tx_index` - Transaction index in block + * * `output_index` - Output index in transaction + * * `position` - Position in commitment tree + * + * # Returns + * The note value in zatoshis if decryption succeeds, 0 otherwise. + */ +uint64_t cw_pivx_try_decrypt_output(int64_t key_handle, + int64_t sync_handle, + const uint8_t *cmu, + const uint8_t *epk, + const uint8_t *enc_ciphertext, + uint32_t height, + uint32_t tx_index, + uint32_t output_index, + uint64_t position); + +/** + * Validate a Sapling address. + */ +uint8_t cw_pivx_validate_address(const char *address, + uint8_t is_testnet); + +char *cw_pivx_version(void); + +/** + * Clear the last error. + */ +void pivx_clear_last_error(void); + +/** + * Free a byte buffer allocated by this library. + * + * # Safety + * The pointer must have been allocated by this library and not already freed. + */ +void pivx_free_buffer(unsigned char *ptr, + uintptr_t len); + +/** + * Free a string allocated by this library. + * + * # Safety + * The pointer must have been allocated by this library and not already freed. + */ +void pivx_free_string(char *s); + +/** + * Get the last error message. + * Returns null if no error occurred. + * Caller must free the returned string with `pivx_free_string`. + */ +char *pivx_get_last_error(void); + +int32_t pivx_sapling_create_from_seed(const uint8_t *seed, + uintptr_t seed_len, + int32_t is_testnet, + int32_t *session_id); + +int32_t pivx_sapling_destroy(int32_t session_id); + +void pivx_sapling_free_string(char *ptr); + +int64_t pivx_sapling_get_balance(int32_t session_id); + +int32_t pivx_sapling_get_sync_height(int32_t session_id); + +int32_t pivx_sapling_init(void); + +/** + * Verify that a server-supplied witness recomputes to the expected anchor. + * + * * `witness_hex` - 32 sibling hashes as hex (2048 hex chars), the same + * serialization `cw_pivx_build_shielded_tx` parses into a spend path. + * * `cmu_hex` - 32-byte note commitment as hex. + * * `anchor_hex` - 32-byte expected anchor (Merkle root) as hex. + * * `position` - Position of the note in the commitment tree. + * + * Returns 1 when the locally recomputed root equals the anchor, 0 on a + * clean mismatch, and -1 on parse or other errors (see + * `cw_pivx_get_last_error`). + */ +int32_t pivx_sapling_verify_witness_root(const char *witness_hex, + const char *cmu_hex, + const char *anchor_hex, + uint64_t position); + +/** + * Caller must free the returned string with `pivx_free_string`. + */ +char *pivx_sapling_version(void); + +#endif /* CW_PIVX_SAPLING_H */ diff --git a/cw_pivx/ios/cw_pivx.podspec b/cw_pivx/ios/cw_pivx.podspec new file mode 100644 index 0000000000..80e74e217e --- /dev/null +++ b/cw_pivx/ios/cw_pivx.podspec @@ -0,0 +1,45 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint cw_pivx.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'cw_pivx' + s.version = '0.0.1' + s.summary = 'PIVX integration for Cake Wallet with Sapling support.' + s.description = <<-DESC +PIVX wallet for Cake Wallet with Sapling shielded transactions, +backed by a native Rust library for proving and note scanning. + DESC + s.homepage = 'https://cakewallet.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Cake Wallet' => 'support@cakewallet.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + s.libraries = 'resolv' + + # Preserve the xcframework + s.preserve_paths = 'Frameworks/**/*' + + # Script to copy the correct .a file based on SDK + copy_script = 'if [[ "$PLATFORM_NAME" == *"simulator"* ]]; then cp "${PODS_TARGET_SRCROOT}/Frameworks/cw_pivx_sapling.xcframework/ios-arm64_x86_64-simulator/libcw_pivx_sapling.a" "${BUILT_PRODUCTS_DIR}/libcw_pivx_sapling.a"; else cp "${PODS_TARGET_SRCROOT}/Frameworks/cw_pivx_sapling.xcframework/ios-arm64/libcw_pivx_sapling.a" "${BUILT_PRODUCTS_DIR}/libcw_pivx_sapling.a"; fi' + + s.script_phase = { + :name => 'Copy PIVX Sapling Library', + :script => copy_script, + :execution_position => :before_compile, + :output_files => ['${BUILT_PRODUCTS_DIR}/libcw_pivx_sapling.a'] + } + + # Flutter.framework does not contain a i386 slice. + # Use -force_load to embed static library symbols into the cw_pivx dynamic framework + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libcw_pivx_sapling.a' + } + s.swift_version = '5.0' + +end diff --git a/cw_pivx/lib/cw_pivx.dart b/cw_pivx/lib/cw_pivx.dart new file mode 100644 index 0000000000..379b9c33f9 --- /dev/null +++ b/cw_pivx/lib/cw_pivx.dart @@ -0,0 +1,8 @@ +export 'src/pivx_wallet.dart'; +export 'src/pivx_wallet_addresses.dart'; +export 'src/pivx_wallet_creation_credentials.dart'; +export 'src/pivx_wallet_service.dart'; +export 'src/pivx_receive_page_options.dart'; +export 'src/pivx_transaction_priority.dart'; +export 'src/pivx_network.dart'; +export 'src/pivx_node_capability.dart'; diff --git a/cw_pivx/lib/src/pending_pivx_shielded_transaction.dart b/cw_pivx/lib/src/pending_pivx_shielded_transaction.dart new file mode 100644 index 0000000000..792f56618e --- /dev/null +++ b/cw_pivx/lib/src/pending_pivx_shielded_transaction.dart @@ -0,0 +1,373 @@ +import 'dart:typed_data'; + +import 'package:cw_bitcoin/electrum.dart'; +import 'package:cw_bitcoin/bitcoin_amount_format.dart'; +import 'package:cw_bitcoin/exceptions.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:convert/convert.dart'; +import 'sapling/sapling_factories.dart' show SaplingTransactionResult; + +class PivxShieldedTransactionDebugSummary { + PivxShieldedTransactionDebugSummary({ + required this.byteLength, + required this.version, + required this.type, + required this.transparentInputCount, + required this.transparentOutputCount, + required this.hasSaplingData, + required this.valueBalance, + required this.shieldedSpendCount, + required this.shieldedOutputCount, + required this.hasBindingSignature, + this.parseError, + }); + + final int byteLength; + final int? version; + final int? type; + final int? transparentInputCount; + final int? transparentOutputCount; + final bool hasSaplingData; + final int? valueBalance; + final int? shieldedSpendCount; + final int? shieldedOutputCount; + final bool hasBindingSignature; + final String? parseError; + + static const int _saplingSpendDescriptionSize = 384; + static const int _saplingOutputDescriptionSize = 948; + static const int _saplingBindingSignatureSize = 64; + + factory PivxShieldedTransactionDebugSummary.fromHex(String txHex) { + try { + return PivxShieldedTransactionDebugSummary._parse(hex.decode(txHex)); + } catch (e) { + return PivxShieldedTransactionDebugSummary( + byteLength: txHex.length ~/ 2, + version: null, + type: null, + transparentInputCount: null, + transparentOutputCount: null, + hasSaplingData: false, + valueBalance: null, + shieldedSpendCount: null, + shieldedOutputCount: null, + hasBindingSignature: false, + parseError: 'parse_failed', + ); + } + } + + factory PivxShieldedTransactionDebugSummary._parse(List bytes) { + var offset = 0; + + int readInt16() { + _require(bytes, offset, 2); + final value = bytes[offset] | (bytes[offset + 1] << 8); + offset += 2; + return value >= 0x8000 ? value - 0x10000 : value; + } + + int readInt64() { + _require(bytes, offset, 8); + final value = Uint8List.fromList(bytes.sublist(offset, offset + 8)) + .buffer + .asByteData() + .getInt64(0, Endian.little); + offset += 8; + return value; + } + + int readUint32() { + _require(bytes, offset, 4); + final value = bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24); + offset += 4; + return value; + } + + int readVarInt() { + _require(bytes, offset, 1); + final first = bytes[offset++]; + if (first < 0xfd) return first; + if (first == 0xfd) { + _require(bytes, offset, 2); + final value = bytes[offset] | (bytes[offset + 1] << 8); + offset += 2; + return value; + } + if (first == 0xfe) { + return readUint32(); + } + _require(bytes, offset, 8); + var value = 0; + for (var i = 0; i < 8; i++) { + value |= bytes[offset + i] << (8 * i); + } + offset += 8; + return value; + } + + void skip(int length) { + _require(bytes, offset, length); + offset += length; + } + + final version = readInt16(); + final type = readInt16(); + final inputCount = readVarInt(); + for (var i = 0; i < inputCount; i++) { + skip(36); + final scriptLength = readVarInt(); + skip(scriptLength); + skip(4); + } + + final outputCount = readVarInt(); + for (var i = 0; i < outputCount; i++) { + skip(8); + final scriptLength = readVarInt(); + skip(scriptLength); + } + + skip(4); // nLockTime + + var hasSaplingData = false; + int? valueBalance; + int? spendCount; + int? shieldedOutputCount; + var hasBindingSignature = false; + + if (offset < bytes.length) { + hasSaplingData = bytes[offset++] != 0; + if (hasSaplingData) { + valueBalance = readInt64(); + spendCount = readVarInt(); + skip(spendCount * _saplingSpendDescriptionSize); + shieldedOutputCount = readVarInt(); + skip(shieldedOutputCount * _saplingOutputDescriptionSize); + if (spendCount > 0 || shieldedOutputCount > 0 || valueBalance != 0) { + skip(_saplingBindingSignatureSize); + hasBindingSignature = true; + } + } + } + + final parseError = offset == bytes.length ? null : 'trailing_bytes'; + return PivxShieldedTransactionDebugSummary( + byteLength: bytes.length, + version: version, + type: type, + transparentInputCount: inputCount, + transparentOutputCount: outputCount, + hasSaplingData: hasSaplingData, + valueBalance: valueBalance, + shieldedSpendCount: spendCount, + shieldedOutputCount: shieldedOutputCount, + hasBindingSignature: hasBindingSignature, + parseError: parseError, + ); + } + + static void _require(List bytes, int offset, int length) { + if (offset + length > bytes.length) { + throw const FormatException('truncated PIVX shielded transaction'); + } + } + + String toLogString() { + return 'bytes=$byteLength version=${version ?? 'unknown'} ' + 'type=${type ?? 'unknown'} vin=${transparentInputCount ?? 'unknown'} ' + 'vout=${transparentOutputCount ?? 'unknown'} ' + 'sapling=${hasSaplingData ? 'present' : 'absent'} ' + 'value_balance=${valueBalance ?? 'unknown'} ' + 'shielded_spends=${shieldedSpendCount ?? 'unknown'} ' + 'shielded_outputs=${shieldedOutputCount ?? 'unknown'} ' + 'binding_sig=${hasBindingSignature ? 'present' : 'absent'} ' + 'parse=${parseError ?? 'ok'}'; + } +} + +/// Wraps a [SaplingTransactionResult] as a [PendingTransaction] so shielded +/// txs are handled uniformly with transparent ones. +class PendingPivxShieldedTransaction with PendingTransaction { + PendingPivxShieldedTransaction({ + required this.result, + required this.electrumClient, + required int amount, + required int fee, + this.onCommit, + this.onBroadcastFailure, + }) : amount = Money.fromInt(amount, CryptoCurrency.pivx), + fee = Money.fromInt(fee, CryptoCurrency.pivx), + _listeners = []; + + final SaplingTransactionResult result; + + final ElectrumClient electrumClient; + + @override + final Money amount; + + @override + final Money fee; + + /// Called after a successful broadcast. + final Future Function(dynamic)? onCommit; + + /// Called when the broadcast itself fails, so the wallet can + /// release the shielded notes reserved for this transaction at build time. + /// Not called when [onCommit] fails after a successful broadcast. + final void Function()? onBroadcastFailure; + + final List _listeners; + + @override + String get id => result.txId; + + @override + String get hex => result.txHex; + + @override + String get amountFormatted => + bitcoinAmountToString(amount: amount.amount.toInt()); + + @override + String get feeFormatted => "$feeFormattedValue PIVX"; + + String get feeFormattedValue => + bitcoinAmountToString(amount: fee.amount.toInt()); + + // wire output count is not a fixed 1 (transparent change, shielded change, + // padded sapling outputs), and nothing keys fee/correctness off it, so report + // null rather than a wrong constant. + @override + int? get outputCount => null; + + static String sanitizeBroadcastError(String error) { + var message = error.trim(); + final lowerMessage = message.toLowerCase(); + + const saplingRejections = { + 'bad-txns-sapling-spend-description-invalid': + 'PIVX node rejected the shielded transaction: Sapling spend proof/signature validation failed.', + 'bad-txns-sapling-output-description-invalid': + 'PIVX node rejected the shielded transaction: Sapling output proof validation failed.', + 'bad-txns-sapling-binding-signature-invalid': + 'PIVX node rejected the shielded transaction: Sapling binding signature validation failed.', + 'bad-txns-shielded-requirements-not-met': + 'PIVX node rejected the shielded transaction: Sapling anchor or nullifier requirements were not met.', + 'bad-txns-sapling-requirements-not-met': + 'PIVX node rejected the shielded transaction: Sapling anchor or nullifier requirements were not met.', + 'bad-txns-nullifier-double-spent': + 'PIVX node rejected the shielded transaction: selected shielded note was already spent.', + 'bad-spend-description-nullifiers-duplicate': + 'PIVX node rejected the shielded transaction: duplicate shielded nullifier.', + 'bad-txns-valuebalance-nonzero': + 'PIVX node rejected the shielded transaction: invalid Sapling value balance.', + 'bad-txns-valuebalance-toolarge': + 'PIVX node rejected the shielded transaction: Sapling value balance is too large.', + 'bad-txns-invalid-sapling-act': + 'PIVX node rejected the shielded transaction: Sapling is not active on this chain height.', + 'bad-txns-invalid-sapling': + 'PIVX node rejected the shielded transaction: invalid Sapling transaction form.', + }; + + for (final entry in saplingRejections.entries) { + if (lowerMessage.contains(entry.key)) { + return '${entry.value} (${entry.key})'; + } + } + + message = message + .replaceAll(RegExp(r'\[[0-9a-fA-F\s]{128,}\]'), '') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + if (message.isEmpty) { + return 'Failed to broadcast shielded transaction'; + } + + const maxLength = 240; + if (message.length > maxLength) { + message = '${message.substring(0, maxLength)}...'; + } + + return message; + } + + void addListener(void Function(dynamic) listener) { + _listeners.add(listener); + } + + @override + Future commit() async { + var broadcasted = false; + try { + int? callId; + final txSummary = PivxShieldedTransactionDebugSummary.fromHex(hex); + printV( + '[PendingPivxShieldedTransaction] Broadcast attempt: ${txSummary.toLogString()}'); + + final txid = await electrumClient.broadcastTransaction( + transactionRaw: hex, + network: null, // PIVX network doesn't need to be specified here + idCallback: (id) => callId = id, + ); + + if (txid.isEmpty) { + final error = + callId == null ? '' : electrumClient.getErrorMessage(callId!); + final message = sanitizeBroadcastError(error); + printV( + '[PendingPivxShieldedTransaction] Broadcast failed: $message; ${txSummary.toLogString()}'); + throw BitcoinTransactionCommitFailed(errorMessage: message); + } + + if (txid.toLowerCase() != id.toLowerCase()) { + printV('[PendingPivxShieldedTransaction] Broadcast txid mismatch'); + throw BitcoinTransactionCommitFailed( + errorMessage: 'Broadcast transaction id mismatch', + ); + } + + broadcasted = true; + + // The transaction is broadcast and on-chain from here. Post-broadcast + // bookkeeping and listeners are best-effort: a failure must not surface as + // a broadcast failure, which could prompt a retry / double send. Log and + // swallow; the next sync reconciles local state. + try { + if (onCommit != null) { + await onCommit!(this); + } + + for (final listener in _listeners) { + listener(this); + } + } catch (e) { + printV( + '[PendingPivxShieldedTransaction] Post-broadcast bookkeeping failed: $e'); + } + } on BitcoinTransactionCommitFailed { + if (!broadcasted) onBroadcastFailure?.call(); + rethrow; + } catch (e) { + if (!broadcasted) onBroadcastFailure?.call(); + throw BitcoinTransactionCommitFailed( + errorMessage: 'Failed to broadcast shielded transaction', + ); + } + } + + @override + Future> commitUR() async { + // UR encoding not supported for shielded transactions yet + return {}; + } +} diff --git a/cw_pivx/lib/src/pivx_network.dart b/cw_pivx/lib/src/pivx_network.dart new file mode 100644 index 0000000000..cfd6481846 --- /dev/null +++ b/cw_pivx/lib/src/pivx_network.dart @@ -0,0 +1,261 @@ +import 'dart:typed_data'; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/base58/base58.dart'; +import 'package:blockchain_utils/bip/bip/bip.dart'; +import 'package:blockchain_utils/bip/coin_conf/coin_conf.dart'; +import 'package:blockchain_utils/bip/coin_conf/coins_name.dart'; +import 'package:blockchain_utils/crypto/quick_crypto.dart'; +import 'package:blockchain_utils/utils/binary/utils.dart'; + +/// PIVX network config from PIVX Core chainparams.cpp base58Prefixes: +/// https://github.com/PIVX-Project/PIVX/blob/master/src/chainparams.cpp +/// BIP44 coin type 119 (SLIP-44), path m/44'/119'/account'/change/index. +final CoinConf pivxMainNetConf = CoinConf( + coinName: const CoinNames("PIVX", "PIVX"), + params: const CoinParams( + p2pkhNetVer: [30], // 0x1E 'D' prefix + p2shNetVer: [13], // 0x0D '6' prefix + wifNetVer: [212], // 0xD4 WIF prefix + ), +); + +final CoinConf pivxTestNetConf = CoinConf( + coinName: const CoinNames("PIVX TestNet", "PIVX"), + params: const CoinParams( + p2pkhNetVer: [139], // 0x8B testnet P2PKH prefix + p2shNetVer: [19], // 0x13 testnet P2SH prefix + wifNetVer: [239], // 0xEF testnet WIF prefix + ), +); + +class PivxNetwork implements BasedUtxoNetwork { + static const PivxNetwork mainnet = PivxNetwork._("pivxMainnet"); + static const PivxNetwork testnet = _PivxTestnet._("pivxTestnet"); + + @override + final String value; + + const PivxNetwork._(this.value); + + @override + CoinConf get conf => pivxMainNetConf; + + @override + List get wifNetVer => conf.params.wifNetVer!; + + /// P2PKH version bytes ('D' addresses). + @override + List get p2pkhNetVer => conf.params.p2pkhNetVer!; + + /// P2SH version bytes ('6' addresses). + @override + List get p2shNetVer => conf.params.p2shNetVer!; + + /// No native SegWit; return "" instead of throwing so address-type + /// detection can fall back. + @override + String get p2wpkhHrp => ""; + + @override + final List supportedAddress = const [ + PubKeyAddressType.p2pk, + P2pkhAddressType.p2pkh, + P2shAddressType.p2pkhInP2sh, + P2shAddressType.p2pkInP2sh, + ]; + + @override + bool get isMainnet => this == PivxNetwork.mainnet; + + @override + List get coins { + // blockchain_utils lacks PIVX; use Bitcoin's Bip44 coin and override coin + // type 119 in derivation. + if (isMainnet) return [Bip44Coins.bitcoin]; + return [Bip44Coins.bitcoinTestnet]; + } + + // PIVX-specific extensions, not part of BasedUtxoNetwork. + + /// Staking address prefix, 'S' addresses. + static const int stakingAddressPrefix = 63; + + /// SLIP-44 coin type. + static const int coinType = 119; + + /// P2P magic bytes. + static const List magicBytes = [0x90, 0xc4, 0xfd, 0xe9]; + + static const int defaultPort = 51472; + + static const int rpcPort = 51473; + + /// Coinbase maturity in blocks. + static const int coinbaseMaturity = 100; + + /// Target block time in seconds. + static const int targetBlockTime = 60; + + /// min gap between header-triggered shield syncs. short so a new block header + /// kicks a sync right away (dedups only header bursts), instead of once a block. + static const int shieldedHeaderSyncMinInterval = 5; + + /// shield sync poll fallback for when the header subscription is dead. + static const int shieldedSyncPollInterval = 20; + + /// minRelayTxFee, sat/kB. + static const int minRelayTxFee = 10000; + + /// Dust relay fee, sat/kB. + static const int dustRelayFee = 30000; + + /// Dust threshold, sat. + static const int dustThreshold = 5460; + + /// PIVX Sapling payment address HRP + static const String saplingPaymentAddressHrp = 'ps'; + + /// PIVX Sapling full viewing key HRP + static const String saplingFullViewingKeyHrp = 'pviews'; + + /// PIVX Sapling incoming viewing key HRP + static const String saplingIncomingViewingKeyHrp = 'pivks'; + + /// PIVX Sapling extended spending key HRP + static const String saplingExtendedSpendingKeyHrp = + 'p-secret-extended-key-main'; + + static bool isValidAddress(String address) { + if (address.startsWith('D') && + address.length >= 26 && + address.length <= 35) { + return true; + } + if (address.startsWith('6') && + address.length >= 26 && + address.length <= 35) { + return true; + } + if (address.startsWith('S') && + address.length >= 26 && + address.length <= 35) { + return true; + } + if (address.startsWith('EXM') && + address.length >= 26 && + address.length <= 35) { + return true; + } + if (address.startsWith('ps') && address.length > 50) { + return true; + } + return false; + } + + static String getAddressType(String address) { + if (address.startsWith('D')) return 'P2PKH'; + if (address.startsWith('6')) return 'P2SH'; + if (address.startsWith('S')) return 'Staking'; + if (address.startsWith('EXM')) return 'Exchange'; + if (address.startsWith('ps')) return 'Sapling'; + return 'Unknown'; + } + + /// Exchange address version bytes ('EXM'), chainparams.cpp + /// EXCHANGE_ADDRESS = {0x01,0xb9,0xa2}. Cannot receive shielded transactions. + static const List exchangeAddressPrefix = [0x01, 0xb9, 0xa2]; + + /// OP_EXCHANGEADDR (script.h), appended to exchange address scriptPubKeys. + static const int opExchangeAddr = 0xe0; + + /// Build the 25-byte P2PKH scriptPubKey hex for a standard PIVX + /// transparent address. Returns "" for unsupported address shapes. + static String p2pkhScriptPubKeyHex(String address) { + try { + final decoded = Base58Decoder.checkDecode(address); + if (decoded.length != 21) return ''; + final pubkeyHash = decoded.sublist(1); + final script = Uint8List(25); + script[0] = 0x76; // OP_DUP + script[1] = 0xa9; // OP_HASH160 + script[2] = 0x14; // push 20 bytes + script.setRange(3, 23, pubkeyHash); + script[23] = 0x88; // OP_EQUALVERIFY + script[24] = 0xac; // OP_CHECKSIG + return script.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } catch (_) { + return ''; + } + } + + /// Scripthash (reversed SHA256 of the scriptPubKey) for P2PKH/staking/ + /// exchange addresses; avoids bitcoin_base's SegWit exceptions. + static String computeScriptHash(String address) { + try { + final decoded = Base58Decoder.checkDecode(address); + if (decoded.isEmpty) return ''; + + Uint8List pubkeyHash; + bool isExchangeAddress = false; + + if (address.startsWith('EXM')) { + // Exchange: 3-byte version prefix [0x01,0xb9,0xa2] + 20-byte pubkey hash. + if (decoded.length < 23) return ''; + pubkeyHash = Uint8List.fromList(decoded.sublist(3)); + isExchangeAddress = true; + } else { + // 1-byte version prefix + 20-byte pubkey hash ('D', 'S', '8'). + pubkeyHash = Uint8List.fromList(decoded.sublist(1)); + } + + if (pubkeyHash.length != 20) return ''; + + Uint8List scriptPubKey; + + if (isExchangeAddress) { + // Exchange scriptPubKey (26 bytes). + scriptPubKey = Uint8List(26); + scriptPubKey[0] = 0xe0; // OP_EXCHANGEADDR + scriptPubKey[1] = 0x76; // OP_DUP + scriptPubKey[2] = 0xa9; // OP_HASH160 + scriptPubKey[3] = 0x14; // Push 20 bytes + scriptPubKey.setRange(4, 24, pubkeyHash); + scriptPubKey[24] = 0x88; // OP_EQUALVERIFY + scriptPubKey[25] = 0xac; // OP_CHECKSIG + } else { + // P2PKH scriptPubKey (25 bytes). + scriptPubKey = Uint8List(25); + scriptPubKey[0] = 0x76; // OP_DUP + scriptPubKey[1] = 0xa9; // OP_HASH160 + scriptPubKey[2] = 0x14; // Push 20 bytes + scriptPubKey.setRange(3, 23, pubkeyHash); + scriptPubKey[23] = 0x88; // OP_EQUALVERIFY + scriptPubKey[24] = 0xac; // OP_CHECKSIG + } + + final hash = QuickCrypto.sha256Hash(scriptPubKey); + + // scripthash is the reversed SHA256. + final reversed = Uint8List.fromList(hash.reversed.toList()); + return BytesUtils.toHexString(reversed); + } catch (e) { + return ''; + } + } +} + +class _PivxTestnet extends PivxNetwork { + const _PivxTestnet._(String value) : super._(value); + + @override + CoinConf get conf => pivxTestNetConf; + + @override + bool get isMainnet => false; + + static const List testnetMagicBytes = [0x45, 0x76, 0x65, 0x21]; + + static const int testnetPort = 51474; + + static const int testnetRpcPort = 51475; +} diff --git a/cw_pivx/lib/src/pivx_node_capability.dart b/cw_pivx/lib/src/pivx_node_capability.dart new file mode 100644 index 0000000000..b16670a03d --- /dev/null +++ b/cw_pivx/lib/src/pivx_node_capability.dart @@ -0,0 +1,29 @@ +import 'package:cw_bitcoin/electrum.dart'; + +import 'sapling/pivx_sapling_electrumx.dart'; + +/// Probe whether the ElectrumX node at [uri] serves the PIVX v1 Sapling +/// contract. Throws on a transient connection or probe failure so callers can +/// tell "node is down, retry later" apart from a determinate "connected but +/// lacks the v1 release contract" (returns false). +Future pivxNodeSupportsSapling({ + required Uri uri, + bool? useSSL, + required bool isTestnet, +}) async { + final client = ElectrumClient(); + try { + await client.connectToUri(uri, useSSL: useSSL); + if (!client.isConnected) { + throw Exception('Could not connect to PIVX node $uri'); + } + final saplingClient = PIVXSaplingElectrumX( + electrumClient: client, + isTestnet: isTestnet, + ); + final capabilities = await saplingClient.probeCapabilities(); + return capabilities.supportsV1ReleaseContract; + } finally { + await client.close(); + } +} diff --git a/cw_pivx/lib/src/pivx_receive_page_options.dart b/cw_pivx/lib/src/pivx_receive_page_options.dart new file mode 100644 index 0000000000..6d4ea541f3 --- /dev/null +++ b/cw_pivx/lib/src/pivx_receive_page_options.dart @@ -0,0 +1,68 @@ +import 'package:cw_core/receive_page_option.dart'; + +enum PivxAddressType { + transparent, + shieldedSapling, +} + +class PivxReceivePageOption implements ReceivePageOption { + const PivxReceivePageOption._(this.type, + {this.iconPath, + this.description, + this.isCommon = false, + this.addAddressWord = false}); + + factory PivxReceivePageOption.fromType(final PivxAddressType type) { + switch (type) { + case PivxAddressType.transparent: + return transparent; + case PivxAddressType.shieldedSapling: + return shieldedSapling; + } + } + + static const transparent = PivxReceivePageOption._( + PivxAddressType.transparent, + description: 'P2PKH', + // shared eye icon, same as Zcash Transparent + iconPath: 'assets/new-ui/address-type-picker-icons/zec/transparent.svg', + isCommon: true, + addAddressWord: true, + ); + static const shieldedSapling = PivxReceivePageOption._( + PivxAddressType.shieldedSapling, + description: 'Sapling', + // shared shield icon, same as Zcash Shielded + iconPath: 'assets/new-ui/address-type-picker-icons/zec/shielded.svg', + isCommon: true, + addAddressWord: true, + ); + + final PivxAddressType type; + final String? iconPath; + final String? description; + final bool isCommon; + final bool addAddressWord; + + String get value { + switch (type) { + case PivxAddressType.transparent: + return "Transparent"; + case PivxAddressType.shieldedSapling: + return "Shielded (Sapling)"; + } + } + + String toString() { + return value; + } + + static const all = [ + PivxReceivePageOption.transparent, + PivxReceivePageOption.shieldedSapling, + ]; + + PivxAddressType toType() { + return type; + } +} diff --git a/cw_pivx/lib/src/pivx_transaction_priority.dart b/cw_pivx/lib/src/pivx_transaction_priority.dart new file mode 100644 index 0000000000..12e6185647 --- /dev/null +++ b/cw_pivx/lib/src/pivx_transaction_priority.dart @@ -0,0 +1,70 @@ +import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; + +/// PIVX transaction priority levels. Fees from PIVX Core: minRelayTxFee +/// 10000 sat/kB, dustRelayFee 30000 sat/kB. Shielded txs require 100x the +/// minimum relay fee. +class PivxTransactionPriority extends BitcoinTransactionPriority { + const PivxTransactionPriority({required String title, required int raw}) + : super(title: title, raw: raw); + + static const List all = [fast, medium, slow]; + + static const PivxTransactionPriority slow = + PivxTransactionPriority(title: 'Slow', raw: 0); + static const PivxTransactionPriority medium = + PivxTransactionPriority(title: 'Medium', raw: 1); + static const PivxTransactionPriority fast = + PivxTransactionPriority(title: 'Fast', raw: 2); + + static PivxTransactionPriority deserialize({required int raw}) { + switch (raw) { + case 0: + return slow; + case 1: + return medium; + case 2: + return fast; + default: + throw Exception( + 'Unexpected token: $raw for PivxTransactionPriority deserialize'); + } + } + + @override + String get units => 'sat/kB'; + + @override + String toString() { + var label = ''; + + switch (this) { + case PivxTransactionPriority.slow: + label = 'Slow'; + break; + case PivxTransactionPriority.medium: + label = 'Medium'; + break; + case PivxTransactionPriority.fast: + label = 'Fast'; + break; + default: + break; + } + + return label; + } + + /// Fee rate in sat/kB: Slow 10000 (minRelayTxFee), Medium 20000, Fast 50000. + int get feeRate { + switch (this) { + case PivxTransactionPriority.slow: + return 10000; // minRelayTxFee + case PivxTransactionPriority.medium: + return 20000; + case PivxTransactionPriority.fast: + return 50000; + default: + return 10000; + } + } +} diff --git a/cw_pivx/lib/src/pivx_wallet.dart b/cw_pivx/lib/src/pivx_wallet.dart new file mode 100644 index 0000000000..8a50fb6bf3 --- /dev/null +++ b/cw_pivx/lib/src/pivx_wallet.dart @@ -0,0 +1,2908 @@ +import 'dart:async'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:bech32/bech32.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/bitcoin_address_record.dart'; +import 'package:cw_bitcoin/bitcoin_amount_format.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; +import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; +import 'package:cw_bitcoin/electrum.dart' as electrum; +import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_bitcoin/electrum_transaction_info.dart'; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; +import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/output_info.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:cw_core/transaction_priority.dart'; +import 'package:cw_pivx/src/pivx_transaction_priority.dart'; +import 'package:cw_core/unspent_coin_type.dart'; +import 'package:cw_core/unspent_coins_info.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_keys_file.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_core/sync_status.dart' as core_sync; +import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:mobx/mobx.dart'; +import 'package:synchronized/synchronized.dart'; + +import 'pivx_network.dart'; +import 'pivx_wallet_addresses.dart'; +import 'pending_pivx_shielded_transaction.dart'; +import 'sapling/sapling_constants.dart'; +import 'sapling/pivx_sapling_electrumx.dart'; +import 'sapling/sapling_factories.dart'; +import 'sapling/sapling_note_storage.dart'; + +part 'pivx_wallet.g.dart'; + +const bool _debugClearPendingShieldedSpends = + bool.fromEnvironment('PIVX_CLEAR_PENDING_SHIELDED_SPENDS'); + +/// PIVX wallet with Sapling shielded support, built on ElectrumWallet. +/// +/// Transparent layer: BIP44 coin type 119, P2PKH 'D' addresses, PIVX Core +/// dust threshold, coinstake-aware balances. Shielded layer: Sapling notes, +/// 'ps' addresses on mainnet, zero-knowledge proofs. Balance splits +/// transparent (UTXO) and shielded (unspent notes); total is the sum. +/// Routes: t->t, t->z (shield), z->z, z->t (deshield). +class PivxWallet = PivxWalletBase with _$PivxWallet; + +abstract class PivxWalletBase extends ElectrumWallet with Store { + static const int _shieldedRestoreAddressReuseScanLimit = 1000; + static const int _shieldedBirthdayRewindBlocks = 1440; + + static String sanitizeShieldSyncError(Object error) { + final text = error.toString().toLowerCase(); + + if (text.contains('tree cursor') || + text.contains('global output positions')) { + return 'PIVX Sapling sync requires a Sapling v1 ElectrumX node with global output positions. Switch nodes and retry.'; + } + if (text.contains('advertises v1') || + text.contains('release contract features')) { + return 'Current PIVX node advertises incomplete Sapling v1 support. Switch to a fully upgraded Sapling v1 node and retry.'; + } + if (text.contains('incomplete range') || + text.contains('partial_index') || + text.contains('index_not_ready') || + text.contains('backend_timeout')) { + return 'Current PIVX node did not return a complete Sapling block range yet. Wait for the node to finish indexing and retry.'; + } + if (text.contains('block scanning') || + text.contains('get_block_range') || + text.contains('rpc method unavailable')) { + return 'Current PIVX node does not support Sapling block scanning. Switch to a Sapling-capable node and retry.'; + } + if (text.contains('network mismatch')) { + return 'Current PIVX node is on the wrong network for this wallet. Switch nodes and retry.'; + } + if (text.contains('activation height mismatch')) { + return 'Current PIVX node reports an unexpected Sapling activation height. Switch nodes and retry.'; + } + + return 'PIVX Sapling sync failed. Check node capability and retry.'; + } + + PivxWalletBase({ + required String mnemonic, + required String password, + required WalletInfo walletInfo, + required DerivationInfo derivationInfo, + required Box unspentCoinsInfo, + required Uint8List seedBytes, + required EncryptionFileUtils encryptionFileUtils, + PivxNetwork pivxNetwork = PivxNetwork.mainnet, + String? passphrase, + BitcoinAddressType? addressPageType, + List? initialAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + electrum.ElectrumClient? electrumClient, + }) : super( + mnemonic: mnemonic, + password: password, + walletInfo: walletInfo, + derivationInfo: derivationInfo, + unspentCoinsInfo: unspentCoinsInfo, + network: pivxNetwork, + initialAddresses: initialAddresses, + initialBalance: initialBalance, + seedBytes: seedBytes, + currency: CryptoCurrency.pivx, + encryptionFileUtils: encryptionFileUtils, + passphrase: passphrase, + electrumClient: electrumClient, + ) { + walletAddresses = PivxWalletAddresses( + walletInfo, + initialAddresses: initialAddresses, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + mainHdByType: mainHdByType, + sideHdByType: sideHdByType, + legacyMainHd: mainHd, + legacySideHd: sideHd, + network: pivxNetwork, + initialAddressPageType: addressPageType, + isHardwareWallet: walletInfo.isHardwareWallet, + ); + autorun((_) { + this.walletAddresses.isEnabledAutoGenerateSubaddress = + this.isEnabledAutoGenerateSubaddress; + }); + } + + @override + Future init() async { + await super.init(); + // Won't throw if the native Sapling lib is unavailable. + await tryInitializeSapling(); + _ensureShieldedHeaderSyncSubscription(); + } + + @override + Future close({bool shouldCleanup = false}) async { + await _shieldedHeaderSyncSubscription?.cancel(); + _shieldedHeaderSyncSubscription = null; + await _mempoolSubscription?.cancel(); + _mempoolSubscription = null; + _shieldedSyncPollTimer?.cancel(); + _shieldedSyncPollTimer = null; + // Free native Sapling handles (prover, sync engine, key manager). + for (final dispose in [ + _saplingTxBuilder?.dispose, + _shieldSyncEngine?.dispose, + _saplingKeyManager?.dispose, + ]) { + try { + dispose?.call(); + } catch (_) {} + } + _saplingTxBuilder = null; + _shieldSyncEngine = null; + _saplingKeyManager = null; + await super.close(shouldCleanup: shouldCleanup); + } + + /// Lazily initialized on first Sapling access. + SaplingKeyManagerWrapper? _saplingKeyManager; + + /// Lazily initialized when Sapling sync starts. + ShieldSyncEngineWrapper? _shieldSyncEngine; + bool _shieldSyncEngineInitialized = false; + + /// Lazily initialized when building shielded transactions. + SaplingTransactionBuilderWrapper? _saplingTxBuilder; + + /// Serializes balance updates against races. + final _balanceLock = Lock(); + + /// Serializes the shared peek-engine mempool decrypt so a push and the poll + /// safety-net can't interleave on it. + final _mempoolLock = Lock(); + + StreamSubscription? _shieldedHeaderSyncSubscription; + DateTime? _lastHeaderTriggeredShieldSync; + + /// Push feed for 0-conf mempool receives, when the node supports it. Replaces + /// the poll for one subscription per session; null means poll fallback. + StreamSubscription? _mempoolSubscription; + + /// Periodic fallback that keeps shielded state live even when the header + /// subscription goes stale (e.g. after a mobile connection drop/reconnect, + /// which is why incoming notes and confirmations only updated on restart). + Timer? _shieldedSyncPollTimer; + + @observable + bool saplingEnabled = true; + + /// Shielded balance in zatoshis (1 PIV = 1e8). + @observable + int shieldedBalance = 0; + + /// Unconfirmed shielded balance in zatoshis. + @observable + int pendingShieldedBalance = 0; + + /// 0-conf shielded receives seen in the mempool. display-only, not spendable; + /// dropped once mined (moves to a confirmed note) or evicted. refreshed by + /// _refreshShieldedMempool on the sync cadence. + List _mempoolIncoming = []; + + int get _mempoolIncomingTotal { + // exclude any that already landed as a confirmed note; a stale snapshot + // after mining would otherwise double-count against pendingShieldedBalance. + final known = + _shieldSyncEngine?.storage.notes.map((n) => n.txid).toSet() ?? + const {}; + return _mempoolIncoming + .where((n) => !known.contains(n.txid)) + .fold(0, (sum, n) => sum + n.value); + } + + /// pending shielded shown to the user: confirmed-but-immature notes plus the + /// 0-conf mempool total. + int get _displayPendingShielded => + pendingShieldedBalance + _mempoolIncomingTotal; + + @observable + int lastShieldSyncedBlock = 0; + + @observable + bool isShieldSyncing = false; + + @observable + String? currentShieldedAddress; + + /// Whether the active node passed the Sapling RPC capability probe. + @observable + bool saplingRpcAvailable = false; + + /// Sanitized shielded sync error for UI/support state. + @observable + String? lastShieldSyncError; + + Money _pivxMoney(int amount) => Money.fromInt(amount, CryptoCurrency.pivx); + + Money get _zeroPivxMoney => Money.zero(CryptoCurrency.pivx); + + int get transparentBalance { + final electrumBalance = balance[currency]; + return electrumBalance?.confirmed.amount.toInt() ?? 0; + } + + int get totalBalance => transparentBalance + shieldedBalance; + + double get totalBalancePivx => totalBalance / 100000000.0; + + double get shieldedBalancePivx => shieldedBalance / 100000000.0; + + /// PIVX supports rescan for both transparent and shielded balances. + @override + bool get hasRescan => true; + + /// Rescan transparent then shielded from [height]. + @override + Future rescan({required int height, bool? doSingleScan}) async { + syncStatus = core_sync.SyncronizingSyncStatus(); + + // don't call super.rescan(): it flips on Bitcoin silent-payment scanning and + // starts a scan stream the pivx server can't answer, throwing a generic + // error dialog. transparent balance/history is always live, so re-fetch it, + // then rescan the shielded pool from height. + try { + await updateTransactions(); + await updateAllUnspents(); + await updateBalance(); + } catch (e) { + printV('[PIVX] transparent refresh during rescan failed: $e'); + } + + await rescanShielded(fromHeight: height); + + syncStatus = core_sync.SyncedSyncStatus(); + } + + /// Initialize Sapling. Seed bytes are zeroed after use; errors leave the + /// wallet in a clean state. + Future initializeSapling() async { + if (_saplingKeyManager != null) return; + + SaplingKeyManagerWrapper? tempKeyManager; + SaplingAddressResult? tempAddress; + Uint8List? saplingSeeds; + + try { + final mnemonic = seed; + if (mnemonic == null) { + throw StateError('Cannot initialize Sapling without mnemonic seed'); + } + saplingSeeds = MnemonicBip39.toSeed(mnemonic, passphrase: passphrase); + + tempKeyManager = await SaplingKeyManagerFactory.create( + seed: saplingSeeds, + isTestnet: network == PivxNetwork.testnet, + accountIndex: 0, + ); + await tempKeyManager.initialize(); + + tempAddress = await tempKeyManager.getDefaultAddress(); + + // Commit only after everything succeeds. + _saplingKeyManager = tempKeyManager; + currentShieldedAddress = tempAddress.encoded; + saplingEnabled = true; + _applySaplingReceiveOptions(); + } catch (e) { + if (tempKeyManager != null) { + try { + tempKeyManager.dispose(); + } catch (_) { + // ignore disposal errors + } + } + + // Native lib not loaded, or other error. + saplingEnabled = false; + _applySaplingReceiveOptions(); + rethrow; + } finally { + // zero seed bytes + if (saplingSeeds != null) { + saplingSeeds.fillRange(0, saplingSeeds.length, 0); + } + } + } + + Future tryInitializeSapling() async { + if (_saplingKeyManager != null) return true; + if (!saplingEnabled) return false; + + try { + await initializeSapling(); + + await _loadShieldedBalanceFromStorage(); + + // Restore notes to the native engine so they're spendable after restart. + await _restoreNotesToNativeEngine(); + + // rebuild shielded history from restored notes on startup; don't wait for + // a successful sync (can fail on a flaky node), or balance shows but + // history stays empty. + try { + await _refreshShieldedTransactionHistory(); + } catch (e) { + printV('[PIVX] Startup shielded history refresh failed'); + } + + return true; + } catch (e) { + printV('[PIVX] Sapling initialization failed'); + saplingEnabled = false; + _applySaplingReceiveOptions(); + return false; + } + } + + /// push saplingEnabled to the address list so the receive page offers the + /// shielded option only when Sapling is available. + void _applySaplingReceiveOptions() { + final addresses = walletAddresses; + if (addresses is PivxWalletAddresses) { + addresses.setSaplingEnabled(saplingEnabled); + } + } + + /// Restore notes from storage to the native engine; Rust SYNC_STATES is + /// empty after restart. + Future _restoreNotesToNativeEngine() async { + if (_saplingKeyManager == null) return; + + try { + _shieldSyncEngine ??= await ShieldSyncEngineFactory.create( + keyManager: _saplingKeyManager!, + walletId: walletInfo.id, + isTestnet: network == PivxNetwork.testnet, + electrumClient: electrumClient, + encryptionFileUtils: encryptionFileUtils, + password: password, + ); + if (!_shieldSyncEngineInitialized) { + await _shieldSyncEngine!.initialize(); + _shieldSyncEngineInitialized = true; + } + _restoreCurrentShieldedAddressFromStorage(); + + await _debugClearPendingShieldedSpendReservations(); + + await _shieldSyncEngine!.restoreNotesFromStorage(); + printV('[PIVX] Restored spendable notes to native engine'); + } catch (e) { + printV('[PIVX] Failed to restore notes to native engine'); + // Don't fail init; notes can be restored during sync. + } + } + + /// Load shielded balance from storage to restore it without a full sync. + Future _loadShieldedBalanceFromStorage() async { + try { + final storage = SaplingNoteStorage( + walletId: walletInfo.id, + isTestnet: network == PivxNetwork.testnet, + encryptionFileUtils: encryptionFileUtils, + password: password, + ); + await storage.load(); + + final storedBalance = storage.spendableBalanceAt( + chainHeight: storage.lastSyncedHeight, + ); + final storedPendingBalance = storage.pendingReceivedBalanceAt( + chainHeight: storage.lastSyncedHeight, + ); + + shieldedBalance = storedBalance; + pendingShieldedBalance = storedPendingBalance; + // Update the balance map directly with shielded balance, including zero, + // so stale shielded display state cannot survive storage reloads. + final currentBalance = balance[currency]; + if (currentBalance != null) { + balance[currency] = ElectrumBalance( + confirmed: currentBalance.confirmed, + unconfirmed: currentBalance.unconfirmed, + frozen: currentBalance.frozen, + secondConfirmed: _pivxMoney(storedBalance), + secondUnconfirmed: _pivxMoney(storedPendingBalance), + ); + } else { + balance[currency] = ElectrumBalance( + confirmed: _zeroPivxMoney, + unconfirmed: _zeroPivxMoney, + frozen: _zeroPivxMoney, + secondConfirmed: _pivxMoney(storedBalance), + secondUnconfirmed: _pivxMoney(storedPendingBalance), + ); + } + } catch (e) { + printV('[PIVX] Failed to load shielded balance from storage'); + } + } + + /// Chain height to count shielded confirmations against. Uses the daemon tip + /// when it leads the Sapling index cursor (the index lags the tip by a small + /// processing window, so the cursor under-reports confirmations near the top), + /// else the index height. Legacy nodes with no daemon height fall back to the + /// index height. Keeps the balance confirmed/pending split consistent with + /// history, which counts the same way. + int get _shieldConfirmationHeight { + final storage = _shieldSyncEngine!.storage; + final indexHeight = storage.lastSyncedHeight; + final daemonHeight = + _shieldSyncEngine!.saplingClient.capabilities?.daemonHeight; + return (daemonHeight != null && daemonHeight > indexHeight) + ? daemonHeight + : indexHeight; + } + + /// True when [error] signals the shielded pool can't fund a send (including + /// the after-fee case). Lets an auto-selected z-to-z fall back to shielding + /// transparent funds (t-to-z) instead of failing. + bool _isInsufficientShieldedFunds(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('insufficient shielded balance') || + message.contains('insufficient balance after fee') || + message.contains('could not select sufficient notes') || + message.contains('no spendable shielded notes'); + } + + /// Reconcile shielded balance: the Rust engine is the source of truth. + /// Locked against concurrent operations. Call after sync, broadcast, + /// restoration, or any note mutation. + Future _reconcileShieldedBalance() async { + if (_shieldSyncEngine == null) { + printV('[PIVX] Cannot reconcile balance: Sync engine not initialized'); + return; + } + + await _balanceLock.synchronized(() async { + try { + final refHeight = _shieldConfirmationHeight; + shieldedBalance = _shieldSyncEngine!.balanceAt(refHeight); + pendingShieldedBalance = _shieldSyncEngine!.pendingBalanceAt(refHeight); + // always push into the balance map, even when the observables already + // match. the sync's onProgress updates shieldedBalance but not the map, + // so the old `if changed` guard skipped the map update and the ui stayed + // on the stale value until a manual pull-to-refresh. this is the + // reconcile that makes a received/confirmed note show on its own. + _applyShieldedBalanceToMap(); + } catch (e) { + printV('[PIVX] Balance reconciliation failed'); + // best-effort; don't rethrow + } + }); + + // keep shielded history in lockstep with balance. refresh used to run only + // in startSync's success path, so header/poll syncs updated balance but left + // history empty. every reconcile now rebuilds history from the same notes. + try { + await _refreshShieldedTransactionHistory(); + } catch (e) { + printV('[PIVX] Shielded tx history refresh failed'); + } + + // 0-conf mempool receives use a separate route, so they aren't gated by the + // confirmed-notes guard above and show even before the first block. + try { + await _refreshShieldedMempoolHistory(); + } catch (e) { + printV('[PIVX] Shielded mempool history refresh failed'); + } + } + + /// Refresh the 0-conf shielded mempool snapshot (network op). Best-effort: + /// null from scanMempool means unavailable, keep the prior snapshot; a + /// non-null list (possibly empty) replaces it. + Future _refreshShieldedMempool() async { + if (_shieldSyncEngine == null) return; + // runs every sync as the safety net even when subscribed: on a reconnect the + // push feed goes silently stale (server drops the sub, no replay), and the + // poll is what keeps 0-conf alive. the push just delivers it faster between. + try { + await _mempoolLock.synchronized(() async { + final result = await _shieldSyncEngine!.scanMempool(); + if (result == null) return; // unavailable this cycle, keep prior snapshot + _applyMempoolResult(result); + }); + } catch (e) { + printV('[PIVX Sapling] Mempool peek failed (non-fatal)'); + } + } + + /// Apply a mempool scan/push result to the 0-conf state. Full replacement: + /// the push feed sends full state (not a diff), and a stale beyond-cap entry + /// in the rare truncated case is cleaned by the node-checked disappeared-tx + /// reconcile rather than lingering. + void _applyMempoolResult(MempoolScanResult result) { + _mempoolIncoming = result.incoming; + } + + /// Subscribe to the node's mempool push feed once per session when supported, + /// so 0-conf receives arrive at push latency (~5s) instead of on the poll. + /// The poll (_refreshShieldedMempool) stays as the fallback when unsupported. + Future _ensureShieldedMempoolSubscription() async { + if (_mempoolSubscription != null || + !saplingEnabled || + _shieldSyncEngine == null) { + return; + } + SaplingRpcCapabilities caps; + try { + caps = await _shieldSyncEngine!.saplingClient.probeCapabilities(); + } catch (_) { + return; + } + if (!caps.supportsMempoolSubscribe) return; + final stream = _shieldSyncEngine!.saplingClient.mempoolSubscribe(); + if (stream == null) return; + _mempoolSubscription = stream.listen((snapshot) async { + if (!saplingEnabled || + _shieldSyncEngine == null || + _saplingKeyManager == null) { + return; + } + try { + await _mempoolLock.synchronized(() async { + final result = + await _shieldSyncEngine!.decryptMempoolSnapshot(snapshot); + _applyMempoolResult(result); + }); + await _reconcileShieldedBalance(); + } catch (e) { + printV('[PIVX Sapling] Mempool push apply failed (non-fatal)'); + } + }, + // stream errored or the socket closed the feed: drop it so the poll + // resumes and the next sync re-subscribes (no silent 0-conf starve). + onError: (Object _) => _resetMempoolSubscription(), + onDone: _resetMempoolSubscription, + cancelOnError: false); + printV('[PIVX Sapling] Subscribed to mempool push feed'); + } + + void _resetMempoolSubscription() { + _mempoolSubscription?.cancel(); + _mempoolSubscription = null; + } + + /// Grace before a still-pending send is treated as gone. A valid PIVX tx + /// mines within a few 60s blocks, so past this window an unmined tx the node + /// no longer has is evicted/replaced, not just slow. + static const Duration _kPendingSpendEvictionGrace = Duration(minutes: 15); + + /// consecutive sync cycles a pending send must be observed missing (past the + /// grace, on a canary-verified healthy node) before its notes are released. + static const int _kEvictionConfirmations = 3; + + /// per-txid streak of consecutive "missing" observations for pending sends. + final Map _shieldedSpendMissStreak = {}; + + /// rotating start for the bounded orphan z-receive node-check each cycle. + int _orphanCheckCursor = 0; + + /// True when the node has no record of [txid] (not in mempool, not mined). + /// getTransactionVerbose returns empty on a network failure too, so fund-side + /// callers pair this with the canary + grace + streak; never act on a bare + /// failure. + Future _shieldedTxMissingFromNode(String txid) async { + try { + final verbose = await electrumClient.getTransactionVerbose(hash: txid); + return verbose.isEmpty; + } catch (_) { + return false; + } + } + + /// Reconcile shielded txs we track as pending against the node. An accepted + /// send or a 0-conf receive can be evicted, replaced, or reorged out and then + /// never resolve, leaving stuck-pending history, locked notes, or stale + /// balance. Node-status checks drive all three cleanups, acting only on a + /// definitive "missing". + Future _reconcileDisappearedShieldedTxs() async { + if (_shieldSyncEngine == null || !electrumClient.isConnected) return; + final storage = _shieldSyncEngine!.storage; + final now = DateTime.now(); + var historyChanged = false; + var balanceDirty = false; + var releasedNotes = false; + + // 1. pending spends. releasing notes is fund-adjacent, so guard hard: a + // canary (a mined tx we hold, definitely on the node) must be FOUND (node + // is healthy, not returning empty for everything), the send must be past + // the grace, and the node must report it missing on several consecutive + // cycles. even then a wrong release only risks a failed respend (nullifier + // conflict), never lost funds. + final spendPendingAt = {}; + for (final note in storage.notes) { + final txid = note.pendingSpendingTxid; + if (txid == null || note.isSpent) continue; + final at = note.pendingSpendAt; + final current = spendPendingAt[txid]; + if (!spendPendingAt.containsKey(txid) || + (at != null && (current == null || at.isBefore(current)))) { + spendPendingAt[txid] = at; + } + } + // drop streak counters for txids that left pending state (mined/cleared). + _shieldedSpendMissStreak + .removeWhere((txid, _) => !spendPendingAt.containsKey(txid)); + if (spendPendingAt.isNotEmpty) { + // canary: a mined tx we hold definitely exists on the node. check a few + // (the first could be reorg-stale) and treat the node as healthy if ANY + // is found, so one stale note can't block release forever. + final canaries = {}; + for (final note in storage.notes) { + if (note.height > 0) { + canaries.add(note.txid); + if (canaries.length >= 3) break; + } + } + var nodeHealthy = false; + for (final canary in canaries) { + if (!await _shieldedTxMissingFromNode(canary)) { + nodeHealthy = true; + break; + } + } + if (nodeHealthy) { + for (final entry in spendPendingAt.entries) { + final at = entry.value; + if (at == null || now.difference(at) < _kPendingSpendEvictionGrace) { + _shieldedSpendMissStreak.remove(entry.key); + continue; + } + if (!await _shieldedTxMissingFromNode(entry.key)) { + _shieldedSpendMissStreak.remove(entry.key); + continue; + } + final streak = (_shieldedSpendMissStreak[entry.key] ?? 0) + 1; + _shieldedSpendMissStreak[entry.key] = streak; + if (streak < _kEvictionConfirmations) continue; + _shieldedSpendMissStreak.remove(entry.key); + final released = await storage.releasePendingSpend(entry.key); + if (released <= 0) continue; + balanceDirty = true; + releasedNotes = true; + final tx = transactionHistory.transactions[entry.key]; + if (tx != null && + tx.direction == TransactionDirection.outgoing && + tx.isPending) { + transactionHistory.transactions.remove(entry.key); + historyChanged = true; + } + } + } + } + + // 2. 0-conf mempool receives: drop any the node no longer has. display only, + // self-healing (re-added next scan if it reappears). + if (_mempoolIncoming.isNotEmpty) { + final kept = []; + for (final note in _mempoolIncoming) { + if (await _shieldedTxMissingFromNode(note.txid)) continue; + kept.add(note); + } + if (kept.length != _mempoolIncoming.length) { + _mempoolIncoming = kept; + balanceDirty = true; + } + } + + // 3. orphaned z-receive entries (no backing note, e.g. reorged out): prune + // the ones the node no longer has. bounded per cycle to cap node queries. + final noteTxids = storage.notes.map((n) => n.txid).toSet(); + final orphans = transactionHistory.transactions.entries + .where((e) => + e.value.additionalInfo['isPivxShielded'] == true && + e.value.additionalInfo['pivxRoute'] == 'z-receive' && + !noteTxids.contains(e.key)) + .map((e) => e.key) + .toList(); + if (orphans.isNotEmpty) { + // check a rotating window each cycle to cap node queries, so stale + // entries past the window aren't starved when earlier ones stay valid. + const window = 15; + final start = + orphans.length <= window ? 0 : _orphanCheckCursor % orphans.length; + for (var i = 0; i < orphans.length && i < window; i++) { + final txid = orphans[(start + i) % orphans.length]; + if (!await _shieldedTxMissingFromNode(txid)) continue; + transactionHistory.transactions.remove(txid); + historyChanged = true; + } + _orphanCheckCursor = (start + window) % orphans.length; + } + + // native restore skipped these notes while they were pending, so re-add + // them or the builder can't select them even though balance shows spendable. + if (releasedNotes) { + try { + await _shieldSyncEngine!.restoreNotesFromStorage(); + } catch (e) { + printV('[PIVX Sapling] Re-restore after spend release failed'); + } + } + + if (historyChanged) await transactionHistory.save(); + if (balanceDirty) await _reconcileShieldedBalance(); + } + + // mirror the current shielded balance into the balance map's second* fields, + // preserving the transparent fields. no network, unlike updateBalance(). + void _applyShieldedBalanceToMap() { + final current = balance[currency]; + balance[currency] = ElectrumBalance( + confirmed: current?.confirmed ?? _zeroPivxMoney, + unconfirmed: current?.unconfirmed ?? _zeroPivxMoney, + frozen: current?.frozen ?? _zeroPivxMoney, + secondConfirmed: _pivxMoney(shieldedBalance), + secondUnconfirmed: _pivxMoney(_displayPendingShielded), + ); + } + + Future _refreshShieldedTransactionHistory() async { + if (_shieldSyncEngine == null) return; + + final storage = _shieldSyncEngine!.storage; + final currentHeight = _shieldConfirmationHeight; + final byTxid = >{}; + for (final note in storage.notes) { + byTxid.putIfAbsent(note.txid, () => []).add(note); + } + printV( + '[PIVX] Shielded history refresh: ${storage.notes.length} notes -> ${byTxid.length} tx group(s)'); + + // don't reconcile (add/update/prune) history against an empty note set. a + // transient-empty storage (mid-rescan, failed sync, interrupted load) would + // prune every saved shielded-receive. skip until we have notes. + if (byTxid.isEmpty) return; + + var changed = false; + // a z-receive entry with no backing note (reorged out) is pruned by the + // node-checked orphan pass in _reconcileDisappearedShieldedTxs, not here, + // so a receive still valid in the mempool isn't dropped without a check. + + // txids that spent our own notes are our sends; the notes they create are + // change returning to the pool, not receives. captured at broadcast + // (pendingSpendingTxid) and after mining (spendingTxid). + final mySpendTxids = {}; + final mySpendHeightsByTxid = {}; + for (final note in storage.notes) { + final spendingTxid = note.spendingTxid; + if (spendingTxid != null) { + mySpendTxids.add(spendingTxid); + final spendingHeight = note.spendingHeight; + if (spendingHeight != null && spendingHeight > 0) { + final previous = mySpendHeightsByTxid[spendingTxid]; + if (previous == null || spendingHeight < previous) { + mySpendHeightsByTxid[spendingTxid] = spendingHeight; + } + } + } + if (note.pendingSpendingTxid != null) { + mySpendTxids.add(note.pendingSpendingTxid!); + } + } + + for (final spend in mySpendHeightsByTxid.entries) { + final existing = transactionHistory.transactions[spend.key]; + if (existing != null && + existing.additionalInfo['isPivxShielded'] == true && + existing.direction == TransactionDirection.outgoing) { + existing.height = spend.value; + existing.confirmations = currentHeight >= spend.value + ? currentHeight - spend.value + 1 + : 0; + existing.isPending = false; + changed = true; + } + } + + for (final entry in byTxid.entries) { + // our own send: keep the outgoing entry recorded at broadcast (with the + // sent amount), refresh its confirmations from the mined change note, and + // drop any incoming we created for the change before the spend was + // detected, so change never shows as "Received shielded". + if (mySpendTxids.contains(entry.key)) { + final existing = transactionHistory.transactions[entry.key]; + if (existing != null && + existing.additionalInfo['isPivxShielded'] == true) { + if (existing.direction == TransactionDirection.incoming) { + transactionHistory.transactions.remove(entry.key); + changed = true; + } else { + final minHeight = entry.value + .map((note) => note.height) + .reduce((a, b) => a < b ? a : b); + if (minHeight > 0) { + existing.height = minHeight; + existing.confirmations = + currentHeight >= minHeight ? currentHeight - minHeight + 1 : 0; + existing.isPending = false; + changed = true; + } + } + } + continue; + } + + final notes = entry.value; + final amount = notes.fold(0, (sum, note) => sum + note.value); + final height = + notes.map((note) => note.height).reduce((a, b) => a < b ? a : b); + final confirmations = height > 0 && currentHeight >= height + ? currentHeight - height + 1 + : 0; + // first non-empty decrypted memo for this receive (usually one output). + final memo = notes + .map((note) => note.memo) + .firstWhere((m) => m != null && m.isNotEmpty, orElse: () => null); + final existing = transactionHistory.transactions[entry.key]; + + if (existing == null) { + transactionHistory.addOne(ElectrumTransactionInfo( + WalletType.pivx, + id: entry.key, + height: height, + amount: _pivxMoney(amount), + fee: _zeroPivxMoney, + direction: TransactionDirection.incoming, + isPending: confirmations < + PivxShieldedConfirmationPolicy.receiveConfirmations, + date: _shieldedNoteDate(notes), + confirmations: confirmations, + additionalInfo: { + 'isPivxShielded': true, + 'pivxPool': 'shielded', + 'pivxRoute': 'z-receive', + 'pivxRequiredConfirmations': + PivxShieldedConfirmationPolicy.receiveConfirmations, + if (memo != null) 'memo': memo, + }, + )); + changed = true; + } else if (existing.additionalInfo['isPivxShielded'] == true) { + if (existing.direction == TransactionDirection.outgoing) { + if (height > 0) { + existing.height = height; + existing.confirmations = confirmations; + existing.isPending = false; + changed = true; + } + continue; + } + + existing.height = height; + existing.amount = _pivxMoney(amount); + existing.confirmations = confirmations; + existing.isPending = + confirmations < PivxShieldedConfirmationPolicy.receiveConfirmations; + // promoting from z-mempool: re-date off the mined block, not the stale + // mempool firstSeen/now, so an import or 0-conf->confirmed flow shows the + // real time. + existing.date = _shieldedNoteDate(notes); + // promote a 0-conf mempool entry to a confirmed receive so the mempool + // prune (which keys off the z-mempool route) leaves it alone. + existing.additionalInfo['pivxRoute'] = 'z-receive'; + if (memo != null) existing.additionalInfo['memo'] = memo; + changed = true; + } + } + + if (changed) { + await transactionHistory.save(); + } + } + + /// Reconcile 0-conf mempool receives into history as pending incoming entries + /// under a distinct 'z-mempool' route, so the confirmed-note prune leaves them + /// alone. Prune any that dropped out of the snapshot or got mined (the + /// confirmed z-receive entry takes over at the same txid once the note lands). + Future _refreshShieldedMempoolHistory() async { + final storage = _shieldSyncEngine?.storage; + final knownTxids = storage?.notes.map((n) => n.txid).toSet() ?? {}; + final liveTxids = _mempoolIncoming.map((n) => n.txid).toSet(); + var changed = false; + + final stale = transactionHistory.transactions.entries + .where((entry) => + entry.value.additionalInfo['pivxRoute'] == 'z-mempool' && + (!liveTxids.contains(entry.key) || knownTxids.contains(entry.key))) + .map((entry) => entry.key) + .toList(); + for (final txid in stale) { + transactionHistory.transactions.remove(txid); + changed = true; + } + + for (final note in _mempoolIncoming) { + if (knownTxids.contains(note.txid)) continue; // confirmed entry wins + final existing = transactionHistory.transactions[note.txid]; + final date = note.firstSeen != null + ? DateTime.fromMillisecondsSinceEpoch(note.firstSeen! * 1000) + : DateTime.now(); + if (existing == null) { + transactionHistory.addOne(ElectrumTransactionInfo( + WalletType.pivx, + id: note.txid, + height: 0, + amount: _pivxMoney(note.value), + fee: _zeroPivxMoney, + direction: TransactionDirection.incoming, + isPending: true, + date: date, + confirmations: 0, + additionalInfo: { + 'isPivxShielded': true, + 'pivxPool': 'shielded', + 'pivxRoute': 'z-mempool', + 'pivxRequiredConfirmations': + PivxShieldedConfirmationPolicy.receiveConfirmations, + }, + )); + changed = true; + } else if (existing.additionalInfo['pivxRoute'] == 'z-mempool') { + existing.amount = _pivxMoney(note.value); + changed = true; + } + } + + if (changed) { + await transactionHistory.save(); + } + } + + /// Date for a shielded receive: the mined block time (all notes here share a + /// txid, so one block), falling back to the earliest scan time for legacy + /// notes stored before blockTime was captured. + DateTime _shieldedNoteDate(List notes) { + for (final note in notes) { + final blockTime = note.blockTime; + if (blockTime != null && blockTime > 0) { + return DateTime.fromMillisecondsSinceEpoch(blockTime * 1000); + } + } + return notes + .map((note) => note.discoveredAt) + .reduce((a, b) => a.isBefore(b) ? a : b); + } + + Future _recordPendingShieldedOutgoing({ + required String txid, + required int amount, + required int fee, + required String toAddress, + String route = 'z-to-z', + }) async { + transactionHistory.addOne(ElectrumTransactionInfo( + WalletType.pivx, + id: txid, + height: 0, + amount: _pivxMoney(amount), + fee: _pivxMoney(fee), + direction: TransactionDirection.outgoing, + isPending: true, + date: DateTime.now(), + confirmations: 0, + to: toAddress, + additionalInfo: { + 'isPivxShielded': true, + 'pivxPool': 'shielded', + 'pivxRoute': route, + 'pivxRequiredConfirmations': + PivxShieldedConfirmationPolicy.receiveConfirmations, + }, + )); + await transactionHistory.save(); + } + + Future _ensureShieldSyncEngineInitialized() async { + await initializeSapling(); + + if (_shieldSyncEngine != null) { + if (!_shieldSyncEngineInitialized) { + await _shieldSyncEngine!.initialize(); + _shieldSyncEngineInitialized = true; + await _debugClearPendingShieldedSpendReservations(); + } + _restoreCurrentShieldedAddressFromStorage(); + return; + } + + _shieldSyncEngine = await ShieldSyncEngineFactory.create( + keyManager: _saplingKeyManager!, + walletId: walletInfo.id, + isTestnet: network == PivxNetwork.testnet, + electrumClient: electrumClient, + encryptionFileUtils: encryptionFileUtils, + password: password, + ); + await _shieldSyncEngine!.initialize(); + _shieldSyncEngineInitialized = true; + _restoreCurrentShieldedAddressFromStorage(); + await _debugClearPendingShieldedSpendReservations(); + } + + void _restoreCurrentShieldedAddressFromStorage() { + final addresses = _shieldSyncEngine?.storage.addresses; + if (addresses == null || addresses.isEmpty) return; + + final current = currentShieldedReceiveAddressFromStorage(addresses); + currentShieldedAddress = current.address; + } + + @visibleForTesting + static StoredShieldedAddress currentShieldedReceiveAddressFromStorage( + List addresses, + ) { + if (addresses.isEmpty) { + throw StateError('No stored PIVX shielded receive addresses'); + } + + return addresses + .reduce((a, b) => a.diversifierIndex >= b.diversifierIndex ? a : b); + } + + Future _debugClearPendingShieldedSpendReservations() async { + if (!kDebugMode || !_debugClearPendingShieldedSpends) return; + if (_shieldSyncEngine == null) return; + + final cleared = await _shieldSyncEngine!.storage.clearPendingSpentNotes(); + final staleHistoryTxids = transactionHistory.transactions.entries + .where((entry) => + entry.value.additionalInfo['isPivxShielded'] == true && + {'z-to-z', 'z-to-t'}.contains(entry.value.additionalInfo['pivxRoute']) && + entry.value.direction == TransactionDirection.outgoing && + entry.value.isPending) + .map((entry) => entry.key) + .toList(growable: false); + + for (final txid in staleHistoryTxids) { + transactionHistory.transactions.remove(txid); + } + + if (staleHistoryTxids.isNotEmpty) { + await transactionHistory.save(); + } + + printV( + '[PIVX Sapling] Debug pending shielded spend cleanup: ' + 'cleared_value=$cleared stale_history=${staleHistoryTxids.length}', + ); + + if (cleared <= 0 && staleHistoryTxids.isEmpty) return; + + printV('[PIVX Sapling] Debug cleared pending shielded spends'); + await _shieldSyncEngine!.restoreNotesFromStorage(); + await _reconcileShieldedBalance(); + } + + Future _ensureSaplingRpcSupportsShieldedSync() async { + await _ensureShieldSyncEngineInitialized(); + final capabilities = + await _shieldSyncEngine!.saplingClient.probeCapabilities(); + if (!capabilities.supportsBlockRange) { + throw StateError( + 'Current PIVX node does not support Sapling block scanning'); + } + saplingRpcAvailable = true; + lastShieldSyncError = null; + } + + Future _ensureSaplingRpcSupportsShieldedSend() async { + await _ensureSaplingRpcSupportsShieldedSync(); + final capabilities = + await _shieldSyncEngine!.saplingClient.probeCapabilities(); + if (!capabilities.supportsBestAnchor || !capabilities.supportsWitness) { + saplingRpcAvailable = false; + lastShieldSyncError = + 'Current PIVX node cannot provide Sapling anchors/witnesses for shielded sends.'; + throw StateError(lastShieldSyncError!); + } + } + + /// Shielded payment address for [index] (default: current). Bech32 ps1... + Future getShieldedAddress({int? index}) async { + if (index != null) { + await initializeSapling(); + final address = await _saplingKeyManager!.deriveAddress(index); + return address; + } + + await _ensureShieldSyncEngineInitialized(); + return currentShieldedAddress!; + } + + List get shieldedAddresses { + if (_shieldSyncEngine == null) return []; + return _shieldSyncEngine!.storage.addresses; + } + + /// Generate a new diversified shielded address. All diversified addresses + /// share one viewing key and the same shielded balance. + Future generateNewShieldedAddress({String? label}) async { + await _ensureShieldSyncEngineInitialized(); + + final index = _shieldSyncEngine!.storage.getAndIncrementDiversifierIndex(); + final address = await _saplingKeyManager!.deriveAddress(index); + + final storedAddress = StoredShieldedAddress( + diversifierIndex: index, + address: address, + label: label, + ); + await _shieldSyncEngine!.storage.addAddress(storedAddress); + + currentShieldedAddress = address; + + return address; + } + + Future updateShieldedAddressLabel(String address, String? label) async { + if (_shieldSyncEngine != null) { + await _shieldSyncEngine!.storage.updateAddressLabel(address, label); + } + } + + /// Scan the chain for incoming shielded notes and update the balance. + /// [fromHeight] defaults to the last synced height. + Future syncShielded({ + int? fromHeight, + SyncProgressCallback? onProgress, + }) async { + if (isShieldSyncing) return; + + await _ensureShieldSyncEngineInitialized(); + + // wait for a stable connection + int retries = 0; + while (!electrumClient.isConnected && retries < 10) { + await Future.delayed(const Duration(milliseconds: 500)); + retries++; + } + if (!electrumClient.isConnected) { + printV('[PIVX Sapling] Connection not available, aborting sync'); + return; + } + + isShieldSyncing = true; + + try { + await _ensureSaplingRpcSupportsShieldedSync(); + + final initialRestoreHeight = await _initialShieldSyncHeight(); + + await _shieldSyncEngine!.startSync( + startHeight: fromHeight ?? initialRestoreHeight, + onProgress: (status) async { + lastShieldSyncedBlock = status.lastSyncedBlock; + + await _balanceLock.synchronized(() async { + final refHeight = _shieldConfirmationHeight; + shieldedBalance = _shieldSyncEngine!.balanceAt(refHeight); + pendingShieldedBalance = + _shieldSyncEngine!.pendingBalanceAt(refHeight); + }); + + // shielded sync must not drive overall syncStatus: it blocks + // transparent sends while shielded catches up (hours after a restore), + // though the transparent chain is synced. shielded progress shows via + // pivxSyncIndicatorText / isShieldSyncing. + onProgress?.call(status); + }, + ); + + // best-effort: a failure here must not skip the reconcile below, or a + // stored note never reaches the balance map (the UI reads that, not the + // shieldedBalance field) or the history, and the receive stays invisible. + try { + await _advanceShieldedDiversifierIndexPastObservedNotes(); + } catch (e) { + printV('[PIVX Sapling] Diversifier advance failed (non-fatal)'); + } + // subscribe to the push feed for faster 0-conf; the poll below stays on as + // the reconnect safety net. + await _ensureShieldedMempoolSubscription(); + // best-effort 0-conf mempool peek before reconcile, so incoming shows in + // balance + history this cycle. null keeps the prior snapshot. + await _refreshShieldedMempool(); + // reconcile also rebuilds history now, so it's the single source of truth. + await _reconcileShieldedBalance(); + // best-effort: clean up sends/receives the node dropped (evicted, replaced, + // reorged out) so they don't stay stuck-pending or lock their notes. + try { + await _reconcileDisappearedShieldedTxs(); + } catch (e) { + printV('[PIVX Sapling] Disappeared-tx reconcile failed (non-fatal)'); + } + saplingRpcAvailable = true; + lastShieldSyncError = null; + } catch (e) { + saplingRpcAvailable = false; + lastShieldSyncError = sanitizeShieldSyncError(e); + rethrow; + } finally { + isShieldSyncing = false; + } + } + + void _ensureShieldedHeaderSyncSubscription() { + if (_shieldedHeaderSyncSubscription != null || !saplingEnabled) { + return; + } + + final subject = electrumClient.chainTipSubscribe(); + if (subject == null) { + return; + } + + _shieldedHeaderSyncSubscription = subject.listen((event) async { + final height = _heightFromHeaderEvent(event); + if (height != null) { + currentChainTip = height; + } + + if (!saplingEnabled || + _saplingKeyManager == null || + _shieldSyncEngine == null || + isShieldSyncing) { + return; + } + if (height != null && + lastShieldSyncedBlock > 0 && + height <= lastShieldSyncedBlock) { + return; + } + + final now = DateTime.now(); + if (!shouldRunShieldedHeaderSync( + lastSyncAt: _lastHeaderTriggeredShieldSync, + now: now, + )) { + return; + } + + _lastHeaderTriggeredShieldSync = now; + try { + printV('[PIVX Sapling] Header-triggered shielded sync'); + await syncShielded(); + } catch (e) { + printV( + '[PIVX Sapling] Header-triggered shielded sync failed: ${sanitizeShieldSyncError(e)}'); + } + }); + } + + /// Periodically re-runs shielded sync so incoming notes and confirmations + /// stay live even if the header subscription dies on a reconnect. syncShielded + /// is re-entrant-guarded and caps cheaply at db_height when caught up, and its + /// completion refreshes balances and confirmations (against daemon_height). + void _ensureShieldedSyncPoll() { + if (_shieldedSyncPollTimer != null || !saplingEnabled) { + return; + } + _shieldedSyncPollTimer = Timer.periodic( + const Duration(seconds: PivxNetwork.shieldedSyncPollInterval), + (_) async { + if (!saplingEnabled || + _saplingKeyManager == null || + _shieldSyncEngine == null || + isShieldSyncing) { + return; + } + try { + await syncShielded(); + } catch (e) { + printV( + '[PIVX Sapling] Polled shielded sync failed: ${sanitizeShieldSyncError(e)}'); + } + }, + ); + } + + @visibleForTesting + static bool shouldRunShieldedHeaderSync({ + required DateTime? lastSyncAt, + required DateTime now, + }) { + if (lastSyncAt == null) return true; + return now.difference(lastSyncAt) >= + const Duration(seconds: PivxNetwork.shieldedHeaderSyncMinInterval); + } + + static int? _heightFromHeaderEvent(Object? event) { + if (event is int) return event; + if (event is num) return event.toInt(); + if (event is Map) { + return _intFromHeaderField(event['height']) ?? + _intFromHeaderField(event['block_height']); + } + return null; + } + + static int? _intFromHeaderField(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + + @visibleForTesting + static core_sync.SyncStatus? syncStatusForShieldProgress(SyncStatus status) { + if (status.blocksRemaining > 0 && status.chainTip > 0) { + final progress = status.lastSyncedBlock / status.chainTip; + return core_sync.SyncingSyncStatus(status.blocksRemaining, progress); + } + + if (status.blocksRemaining == 0 && status.progress >= 1.0) { + return core_sync.SyncedSyncStatus(); + } + + return null; + } + + Future _initialShieldSyncHeight() async { + if (_shieldSyncEngine!.storage.lastSyncedHeight != 0) { + return null; + } + + final activationHeight = _shieldSyncEngine!.saplingClient.activationHeight; + if (walletInfo.restoreHeight > 0) { + return walletInfo.restoreHeight < activationHeight + ? activationHeight + : walletInfo.restoreHeight; + } + + if (walletInfo.isRecovery) { + return null; + } + + try { + final tip = await electrumClient.getCurrentBlockChainTip(); + if (tip == null || tip <= activationHeight) { + return null; + } + + final birthdayHeight = _estimateShieldedBirthdayHeight( + chainTip: tip, + activationHeight: activationHeight, + ); + await walletInfo.updateRestoreHeight(birthdayHeight); + return birthdayHeight; + } catch (_) { + return null; + } + } + + int _estimateShieldedBirthdayHeight({ + required int chainTip, + required int activationHeight, + }) { + final createdAt = DateTime.fromMillisecondsSinceEpoch(walletInfo.timestamp); + final age = DateTime.now().difference(createdAt); + final ageInBlocks = age.isNegative ? 0 : age.inMinutes; + final height = chainTip - ageInBlocks - _shieldedBirthdayRewindBlocks; + if (height < activationHeight) { + return activationHeight; + } + if (height > chainTip) { + return chainTip; + } + return height; + } + + /// Also syncs shielded notes after the transparent sync. + @override + @action + Future startSync() async { + await super.startSync(); + + if (saplingEnabled && _saplingKeyManager != null) { + _ensureShieldedHeaderSyncSubscription(); + _ensureShieldedSyncPoll(); + try { + await syncShielded(); + + // reconcile (locks internally) + await _reconcileShieldedBalance(); + await updateBalance(); + } catch (e) { + if (kDebugMode) { + printV('[PIVX] Shielded sync debug: ${e.runtimeType}: $e'); + } + printV('[PIVX] Shielded sync failed: ${sanitizeShieldSyncError(e)}'); + // Don't fail the whole sync if shielded sync fails. + } + } + } + + /// Clear stored notes and rescan from [fromHeight] (Sapling activation + /// height when null). Use when notes lack spending data or the balance is + /// wrong. + Future rescanShielded({ + int? fromHeight, + void Function(SyncStatus)? onProgress, + }) async { + await _ensureShieldSyncEngineInitialized(); + + // Stop any in-flight sync and wait for it to unwind before clearing storage + // and resetting the native engine. Otherwise the running pass faults on the + // reset handle (surfacing as a generic error), and the resync below would + // early-return while a sync is still marked active. + _shieldSyncEngine!.requestStop(); + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (isShieldSyncing && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + await _shieldSyncEngine!.storage.clear(); + _shieldSyncEngine!.resetNativeEngine(); + + await syncShielded(fromHeight: fromHeight, onProgress: onProgress); + } + + Future _advanceShieldedDiversifierIndexPastObservedNotes() async { + if (_saplingKeyManager == null || _shieldSyncEngine == null) { + return; + } + + final observedAddressHexes = {}; + for (final note in _shieldSyncEngine!.storage.notes) { + final addressHex = _storedSaplingNoteAddressHex(note); + if (addressHex != null) { + observedAddressHexes.add(addressHex); + } + } + if (observedAddressHexes.isEmpty) { + return; + } + + final nextIndex = await nextShieldedDiversifierIndexAfterObservedAddresses( + currentNextDiversifierIndex: + _shieldSyncEngine!.storage.nextDiversifierIndex, + observedAddressHexes: observedAddressHexes, + deriveAddressHex: (index) async { + final derived = await _saplingKeyManager!.deriveAddress(index); + return _decodeSaplingPaymentAddressHex(derived); + }, + ); + await _shieldSyncEngine!.storage + .advanceNextDiversifierIndexAtLeast(nextIndex); + } + + @visibleForTesting + static Future nextShieldedDiversifierIndexAfterObservedAddresses({ + required int currentNextDiversifierIndex, + required Set observedAddressHexes, + required Future Function(int index) deriveAddressHex, + int scanLimit = _shieldedRestoreAddressReuseScanLimit, + }) async { + final remainingObservedHexes = + observedAddressHexes.map((address) => address.toLowerCase()).toSet(); + if (remainingObservedHexes.isEmpty) { + return currentNextDiversifierIndex; + } + + var highestRecoveredIndex = currentNextDiversifierIndex - 1; + for (var index = 0; + index < scanLimit && remainingObservedHexes.isNotEmpty; + index++) { + final derivedHex = (await deriveAddressHex(index))?.toLowerCase(); + if (derivedHex != null && remainingObservedHexes.remove(derivedHex)) { + highestRecoveredIndex = index; + } + } + + final nextIndex = highestRecoveredIndex + 1; + return nextIndex > currentNextDiversifierIndex + ? nextIndex + : currentNextDiversifierIndex; + } + + String? _storedSaplingNoteAddressHex(StoredSaplingNote note) { + final address = note.address; + if (address != null && _isHexOfLength(address, 86)) { + return address.toLowerCase(); + } + + final diversifier = note.diversifier; + final pkD = note.pkD; + if (_isHexOfLength(diversifier, 22) && _isHexOfLength(pkD, 64)) { + return '${diversifier!.toLowerCase()}${pkD!.toLowerCase()}'; + } + + return null; + } + + bool _isHexOfLength(String? value, int length) { + if (value == null || value.length != length) { + return false; + } + return RegExp(r'^[0-9a-fA-F]+$').hasMatch(value); + } + + String? _decodeSaplingPaymentAddressHex(String encodedAddress) { + try { + final decoded = + const Bech32Codec().decode(encodedAddress, encodedAddress.length); + final expectedHrp = network == PivxNetwork.testnet + ? PivxSaplingNetwork.testnetPaymentAddressHrp + : PivxSaplingNetwork.mainnetPaymentAddressHrp; + if (decoded.hrp != expectedHrp) { + return null; + } + + final bytes = _convertBits(decoded.data, 5, 8, false); + if (bytes.length != kSaplingPaymentAddressSize) { + return null; + } + return hex.encode(bytes); + } catch (_) { + return null; + } + } + + List _convertBits(List data, int inBits, int outBits, bool pad) { + var value = 0; + var bits = 0; + final maxV = (1 << outBits) - 1; + final result = []; + + for (final dataValue in data) { + if (dataValue < 0 || dataValue >> inBits != 0) { + throw ArgumentError('Invalid Bech32 data value'); + } + + value = (value << inBits) | dataValue; + bits += inBits; + + while (bits >= outBits) { + bits -= outBits; + result.add((value >> bits) & maxV); + } + } + + if (pad) { + if (bits > 0) { + result.add((value << (outBits - bits)) & maxV); + } + } else if (bits >= inBits || ((value << (outBits - bits)) & maxV) != 0) { + throw ArgumentError('Invalid Bech32 padding'); + } + + return result; + } + + /// Error message used when reserved (build-time locked) notes are the + /// reason a shielded spend cannot be funded. + @visibleForTesting + static const String shieldedNotesLockedMessage = + 'Insufficient shielded balance: notes are locked by a pending transaction.'; + + /// Nullifiers of notes selected by built-but-not-yet-committed shielded + /// transactions, keyed by pending transaction id. + /// + /// In-memory only: a restart drops the pending transaction objects too, so + /// the reservations must not survive it either. + final Map> _reservedShieldedNotes = + >{}; + + /// All nullifiers currently reserved by pending shielded transactions. + @visibleForTesting + Set get reservedShieldedNullifiers => + _reservedShieldedNotes.values.expand((nullifiers) => nullifiers).toSet(); + + /// Reserve the notes selected for pending transaction [txId]. + /// + /// Throws the standard insufficient-shielded-funds [Exception] when any + /// nullifier is already reserved by a different pending transaction, so a + /// second build can never share notes with an uncommitted first build. + @visibleForTesting + void reserveShieldedNotes(String txId, Iterable nullifiers) { + final requested = nullifiers.toSet(); + if (requested.isEmpty) return; + final reservedByOthers = _reservedShieldedNotes.entries + .where((entry) => entry.key != txId) + .expand((entry) => entry.value) + .toSet(); + if (requested.any(reservedByOthers.contains)) { + throw Exception(shieldedNotesLockedMessage); + } + _reservedShieldedNotes[txId] = requested; + } + + /// Release the note reservation held by pending transaction [txId]. + /// Idempotent: releasing an unknown or already released id is a no-op. + @visibleForTesting + void releaseReservedShieldedNotes(String txId) { + _reservedShieldedNotes.remove(txId); + } + + /// Fail fast with [shieldedNotesLockedMessage] when reserved notes are what + /// makes [amount] unreachable, or when a spend-all would have to consume a + /// reserved note. When the balance is insufficient regardless of + /// reservations this returns normally so the builder reports its usual + /// insufficient-balance error. + @visibleForTesting + static void ensureShieldedNotesNotLocked({ + required Map spendableNoteValuesByNullifier, + required Set reservedNullifiers, + required int amount, + required bool spendAll, + }) { + if (reservedNullifiers.isEmpty) return; + var total = 0; + var unreservedTotal = 0; + var anyReservedSpendable = false; + spendableNoteValuesByNullifier.forEach((nullifier, value) { + total += value; + if (reservedNullifiers.contains(nullifier)) { + anyReservedSpendable = true; + } else { + unreservedTotal += value; + } + }); + if (!anyReservedSpendable) return; + if (spendAll || (unreservedTotal < amount && total >= amount)) { + throw Exception(shieldedNotesLockedMessage); + } + } + + /// Drop reservations whose notes have all left the unspent set (terminally + /// spent or pending-spent via a committed transaction or sync). Storage + /// already excludes those notes from selection, so the stale reservation + /// would only lock a rebuilt spend out of its funds. + void _pruneStaleShieldedNoteReservations() { + if (_reservedShieldedNotes.isEmpty) return; + final unspentNullifiers = {}; + for (final note in _shieldSyncEngine!.storage.unspentNotes) { + final nullifier = note.nullifier; + if (nullifier != null) unspentNullifiers.add(nullifier); + } + _reservedShieldedNotes.removeWhere( + (_, nullifiers) => !nullifiers.any(unspentNullifiers.contains)); + } + + /// Build a signed shielded transaction. [amount] in zatoshis; [memo] up to + /// 512 bytes, shielded outputs only. + Future createShieldedTransaction({ + required String toAddress, + required int amount, + String? memo, + bool useShieldedInputs = true, + bool spendAllShieldedInputs = false, + }) async { + await _ensureShieldSyncEngineInitialized(); + await _ensureSaplingRpcSupportsShieldedSend(); + + // Lock: concurrent builds could select the same notes (double-spend) or + // read inconsistent balance state. + return await _balanceLock.synchronized(() async { + _pruneStaleShieldedNoteReservations(); + ensureShieldedNotesNotLocked( + spendableNoteValuesByNullifier: { + for (final note in _shieldSyncEngine!.storage.spendableNotesAt( + chainHeight: _shieldSyncEngine!.storage.lastSyncedHeight, + )) + if (note.nullifier != null) note.nullifier!: note.value, + }, + reservedNullifiers: reservedShieldedNullifiers, + amount: amount, + spendAll: spendAllShieldedInputs, + ); + + if (_saplingTxBuilder == null) { + _saplingTxBuilder = await SaplingTransactionBuilderFactory.create( + keyManager: _saplingKeyManager!, + syncEngine: _shieldSyncEngine!, + isTestnet: network == PivxNetwork.testnet, + ); + } + + await _ensureProvingParamsLoaded(); + + final options = SaplingTransactionOptions( + toAddress: toAddress, + amount: amount, + memo: memo, + useShieldedInputs: useShieldedInputs, + spendAllShieldedInputs: spendAllShieldedInputs, + ); + + // exclude notes reserved by an in-flight build so a concurrent send picks + // different notes instead of overlapping and failing after the proof. + final result = await _saplingTxBuilder!.buildTransaction( + options: options, + reservedNullifiers: reservedShieldedNullifiers, + ); + reserveShieldedNotes(result.txId, result.spentNullifiers); + return result; + }); + } + + /// Shield transparent funds into the shielded pool. [amount] in zatoshis, + /// null shields all available. + Future shieldFunds({int? amount}) async { + await initializeSapling(); + final destination = (await _saplingKeyManager!.getDefaultAddress()).encoded; + final built = await _buildShieldTransactionResult( + toAddress: destination, + requestedAmount: amount, + isSendAll: amount == null, + ); + return built.result; + } + + /// Build a t-to-z (shield) transaction spending transparent P2PKH UTXOs + /// into a Sapling output, with transparent change. + Future<_BuiltShieldTransaction> _buildShieldTransactionResult({ + required String toAddress, + int? requestedAmount, + required bool isSendAll, + String? memo, + }) async { + await initializeSapling(); + await _ensureShieldSyncEngineInitialized(); + + // Confirmed, spendable, standard P2PKH transparent UTXOs only. + final available = unspentCoins + .where((utx) => + utx.isSending && + !utx.isFrozen && + (utx.confirmations ?? 0) > 0 && + PivxNetwork.p2pkhScriptPubKeyHex(utx.bitcoinAddressRecord.address) + .isNotEmpty) + .toList() + ..sort((a, b) => b.value.compareTo(a.value)); + if (available.isEmpty) { + throw Exception('No spendable transparent PIVX coins available.'); + } + + List selected; + int amount; + ShieldedSpendPlan plan; + if (isSendAll) { + selected = available; + final total = selected.fold(0, (sum, utx) => sum + utx.value); + final fee = PivxFeePolicy.saplingFee( + saplingOutputs: 1, + transparentInputs: selected.length, + ); + amount = total - fee; + if (amount < PivxFeePolicy.shieldedDustThreshold) { + throw Exception('Insufficient transparent balance after PIVX fee.'); + } + plan = ShieldedSpendPlan(fee: fee, change: 0, canBuild: true); + } else { + amount = requestedAmount!; + if (amount < PivxFeePolicy.shieldedDustThreshold) { + throw Exception('Amount below PIVX shielded dust threshold'); + } + selected = []; + var total = 0; + plan = ShieldedSpendPlan(fee: 0, change: 0, canBuild: false); + for (final utx in available) { + selected.add(utx); + total += utx.value; + plan = SaplingTransactionBuilderWrapper.planShieldSpend( + totalInput: total, + amount: amount, + transparentInputs: selected.length, + ); + if (plan.canBuild) break; + } + if (!plan.canBuild) { + throw Exception('Insufficient transparent balance for shield amount.'); + } + } + + final utxoMaps = selected.map((utx) { + final record = utx.bitcoinAddressRecord; + final privateKey = _transparentSigningKeyHexFor(record); + final scriptPubKey = PivxNetwork.p2pkhScriptPubKeyHex(record.address); + return { + 'txid': utx.hash, + 'vout': utx.vout, + 'value': utx.value, + 'script_pubkey': scriptPubKey, + 'private_key': privateKey, + }; + }).toList(growable: false); + + // shield change is transparent leftover, needs a base58 addr. + // walletAddresses.address can be the selected ps1 shielded addr, which fails + // base58 decode in rust, so pull a real transparent change addr. + final changeAddress = plan.change > 0 + ? (await walletAddresses.getChangeAddress()).address + : null; + + final result = await _balanceLock.synchronized(() async { + _saplingTxBuilder ??= await SaplingTransactionBuilderFactory.create( + keyManager: _saplingKeyManager!, + syncEngine: _shieldSyncEngine!, + isTestnet: network == PivxNetwork.testnet, + ); + await _ensureProvingParamsLoaded(); + return await _saplingTxBuilder!.buildShieldTransaction( + utxos: utxoMaps, + toAddress: toAddress, + amount: amount, + memo: memo, + fee: plan.fee, + changeAddress: changeAddress, + change: plan.change, + ); + }); + + return _BuiltShieldTransaction( + result: result, + amount: amount, + fee: result.fee, + ); + } + + /// Derive the transparent signing key for [record], verifying the derived + /// address matches record.address first. The shield builder hands raw keys to + /// Rust, which fails closed on a mismatch ("UTXO private key does not match the + /// script public key hash"). Try the record's branch/derivation (per-type HD + /// map, then legacy, then opposite branch for stale metadata), verify, and + /// throw if no key reproduces the address rather than emit one that can't sign. + String _transparentSigningKeyHexFor(BaseBitcoinAddressRecord record) { + final candidates = [ + record.isHidden + ? (sideHdByType[record.type] ?? sideHd) + : (mainHdByType[record.type] ?? mainHd), + record.isHidden ? sideHd : mainHd, + record.isHidden + ? (mainHdByType[record.type] ?? mainHd) + : (sideHdByType[record.type] ?? sideHd), + record.isHidden ? mainHd : sideHd, + ]; + + for (final hd in candidates) { + final derived = walletAddresses.getAddress( + index: record.index, hd: hd, addressType: record.type); + if (derived == record.address) { + return ECPrivate(hd.childKey(Bip32KeyIndex(record.index)).privateKey) + .toHex(); + } + } + + throw Exception( + 'PIVX transparent input ${record.address} (index ${record.index}) has no ' + 'matching wallet key; refusing to sign with a mismatched key.', + ); + } + + /// Deshield funds into the transparent pool. [amount] in zatoshis; + /// [toAddress] defaults to own address. + Future deshieldFunds({ + required int amount, + String? toAddress, + }) async { + final destination = toAddress ?? walletAddresses.address; + if (_isShieldedAddress(destination)) { + throw Exception('Deshield destination must be a transparent address.'); + } + return await createShieldedTransaction( + toAddress: destination, + amount: amount, + useShieldedInputs: true, + ); + } + + /// Download and load Sapling proving params (~51MB, needed for Groth16 + /// proofs). + Future _ensureProvingParamsLoaded() async { + if (_saplingTxBuilder == null) { + throw StateError('Transaction builder not initialized'); + } + + if (_saplingTxBuilder!.hasProvingParams) { + return; + } + + final appDir = await getApplicationDocumentsDirectory(); + final provingParamsPath = '${appDir.path}/pivx_sapling_params'; + + // Provision params: prefer the bundled asset (instant), fall back to the + // network download only for builds compiled without the bundle. + if (!await _saplingTxBuilder!.hasLocalProvingParams(provingParamsPath)) { + final fromBundle = await _saplingTxBuilder! + .copyProvingParamsFromBundle(provingParamsPath); + if (fromBundle) { + printV('PIVX Sapling proving parameters provisioned from app bundle.'); + } else { + printV('Downloading PIVX Sapling proving parameters (~51MB)...'); + await _saplingTxBuilder!.downloadProvingParams( + path: provingParamsPath, + onProgress: (progress) { + printV( + 'Proving params download: ${(progress * 100).toStringAsFixed(1)}%'); + }, + ); + printV('Proving parameters downloaded successfully.'); + } + } + + printV('Loading PIVX Sapling proving parameters...'); + await _saplingTxBuilder!.loadProvingParams(path: provingParamsPath); + printV('Proving parameters loaded successfully.'); + } + + bool isValidShieldedAddress(String address) { + final isTestnet = network == PivxNetwork.testnet; + if (isTestnet) { + return address.startsWith(PivxSaplingNetwork.testnetPaymentAddressHrp); + } + return address.startsWith(PivxSaplingNetwork.mainnetPaymentAddressHrp); + } + + /// PIVX has no SegWit/MWEB; skip those checks and use custom scripthash. + @override + Future fetchBalances() async { + final addresses = walletAddresses.allAddresses + .where((address) => address.address.isNotEmpty) + .toList(); + + // One unique scripthash per on-chain address. allAddresses can briefly hold + // the same address under more than one record during discovery (a later + // addAddresses de-dups via toSet, which is why the doubling self-heals); + // summing a scripthash twice would double the transparent balance in that + // window. Custom scripthash avoids SegWit exceptions. + final validAddresses = []; + final validScriptHashes = []; + final seenScriptHashes = {}; + for (final addressRecord in addresses) { + final sh = PivxNetwork.computeScriptHash(addressRecord.address); + if (sh.isEmpty || !seenScriptHashes.add(sh)) continue; + validAddresses.add(addressRecord); + validScriptHashes.add(sh); + } + + var totalFrozen = 0; + var totalConfirmed = 0; + var totalUnconfirmed = 0; + + unspentCoinsInfo.values.forEach((info) { + unspentCoins.forEach((element) { + if (element.hash == info.hash && + element.vout == info.vout && + element.bitcoinAddressRecord.address == info.address && + element.value == info.value) { + if (info.isFrozen) { + totalFrozen += element.value; + } + } + }); + }); + + // Batch all balances in one message instead of one get_balance per + // scripthash: the server throttles per message, not per method (verified). + var balanceBySh = >{}; + try { + const chunkSize = 150; + for (var i = 0; i < validScriptHashes.length; i += chunkSize) { + final end = i + chunkSize < validScriptHashes.length + ? i + chunkSize + : validScriptHashes.length; + balanceBySh.addAll(await electrumClient + .getBatchBalance(validScriptHashes.sublist(i, end))); + } + } catch (e) { + // whole batch failed: treat as all-missed so the wipeout guard keeps the + // last-known balance instead of zeroing. + printV('[PIVX] batch get_balance failed: $e'); + balanceBySh = {}; + } + + // getBatchBalance returns {} for a scripthash that errored, so one transient + // miss looks identical to "malformed". A restored wallet queries many + // addresses against a flaky server, so one failure must not zero the whole + // balance. Sum the good responses, keep last-known for failed ones, and treat + // only a full wipeout (every query failed) as a lost connection. + var failedCount = 0; + for (var i = 0; i < validScriptHashes.length; i++) { + final balance = + balanceBySh[validScriptHashes[i]] ?? const {}; + final addressRecord = validAddresses[i]; + final hasKeys = + balance['confirmed'] != null && balance['unconfirmed'] != null; + + if (!hasKeys) { + failedCount++; + // ponytail: last-known so a transient miss flickers, never zeros. + totalConfirmed += addressRecord.balance; + continue; + } + + final confirmed = balance['confirmed'] as int? ?? 0; + final unconfirmed = balance['unconfirmed'] as int? ?? 0; + totalConfirmed += confirmed; + totalUnconfirmed += unconfirmed; + + addressRecord.balance = confirmed + unconfirmed; + if (confirmed > 0 || unconfirmed > 0) { + addressRecord.setAsUsed(); + } + } + + if (validScriptHashes.isNotEmpty && + failedCount == validScriptHashes.length) { + printV('[PIVX] All transparent balance queries failed; connection lost'); + syncStatus = core_sync.LostConnectionSyncStatus(); + final previousBalance = balance[currency]; + + return ElectrumBalance( + confirmed: previousBalance?.confirmed ?? _zeroPivxMoney, + unconfirmed: previousBalance?.unconfirmed ?? _zeroPivxMoney, + frozen: previousBalance?.frozen ?? _zeroPivxMoney, + secondConfirmed: _pivxMoney(shieldedBalance), + secondUnconfirmed: _pivxMoney(_displayPendingShielded), + ); + } + + // Primary balance is transparent; shielded is carried as the secondary. + return ElectrumBalance( + confirmed: _pivxMoney(totalConfirmed), + unconfirmed: _pivxMoney(totalUnconfirmed), + frozen: _pivxMoney(totalFrozen), + secondConfirmed: _pivxMoney(shieldedBalance), + secondUnconfirmed: _pivxMoney(_displayPendingShielded), + ); + } + + /// Custom scripthash health check; PIVX has no SegWit. + @override + Future checkNodeHealth() async { + try { + final addresses = walletAddresses.allAddresses + .where((address) => address.address.isNotEmpty) + .toList(); + + if (addresses.isEmpty) { + return false; + } + + final firstAddress = addresses.first; + final sh = PivxNetwork.computeScriptHash(firstAddress.address); + if (sh.isEmpty) return false; + + await electrumClient.getBalance(sh, throwOnError: true); + if (saplingEnabled) { + await _ensureSaplingRpcSupportsShieldedSync(); + } + return true; + } catch (e) { + return false; + } + } + + // Keep the base's generic batch paths off: they derive confirmations from + // fetchTransactionInfoBatch -> BtcTransaction.fromRaw, which can't parse a + // Sapling funding tx and returns null, dropping a valid transparent coin + // received from a shielded tx. PIVX batches unspents itself in + // fetchUnspentsForAddresses (below) and balances in fetchBalances, both with + // height-derived confirmations, so this only guards paths PIVX doesn't + // override. + @override + bool get shouldUseBatchFetching => false; + + /// Uses custom PIVX scripthash. + @override + Future?> fetchUnspent( + BitcoinAddressRecord address) async { + List updatedUnspentCoins = []; + + final sh = PivxNetwork.computeScriptHash(address.address); + if (sh.isEmpty) return []; + + final unspents = await electrumClient.getListUnspent(sh); + if (unspents == null) return null; + + final tip = await getCurrentChainTip(); + await Future.wait(unspents.map((unspent) async { + try { + final coin = BitcoinUnspent.fromJSON(address, unspent); + coin.isChange = address.isHidden; + // confirmations from the UTXO's own height in listunspent. the tx-info + // path uses BtcTransaction.fromRaw, which can't parse a Sapling funding + // tx and returns null, so a transparent coin received from a shielded tx + // (z->t or shield change) got null confirmations and was dropped as + // unspendable. + // a UTXO with a block height is confirmed even if our cached tip is + // stale (tip < height right after a new block). clamp to 1 there so it + // stays spendable instead of getting dropped by the confirmed filter. + final height = unspent['height'] as int?; + coin.confirmations = (height != null && height > 0) + ? (tip >= height ? tip - height + 1 : 1) + : 0; + updatedUnspentCoins.add(coin); + } catch (_) {} + })); + + return updatedUnspentCoins; + } + + /// Batch every address's unspents in one message instead of one listunspent + /// per address. The server throttles per message, not per method (verified), + /// so this collapses N throttled round trips into ceil(N/150) batched ones. + /// Custom PIVX scripthash + height-derived confirmations, so it avoids the + /// base batch path's getScriptHash (throws for base58) and + /// fetchTransactionInfoBatch (drops sapling-funded coins). + @override + Future?>> fetchUnspentsForAddresses( + List addresses, + ) async { + const chunkSize = 150; + final shByIndex = []; + final ownerBySh = {}; + for (final address in addresses) { + final sh = PivxNetwork.computeScriptHash(address.address); + shByIndex.add(sh); + if (sh.isNotEmpty) ownerBySh.putIfAbsent(sh, () => address); + } + + final uniqueSh = ownerBySh.keys.toList(); + if (uniqueSh.isEmpty) { + return List?>.filled( + addresses.length, []); + } + + final tip = await getCurrentChainTip(); + final unspentBySh = >>{}; + try { + for (var i = 0; i < uniqueSh.length; i += chunkSize) { + final end = + i + chunkSize < uniqueSh.length ? i + chunkSize : uniqueSh.length; + unspentBySh.addAll( + await electrumClient.getBatchUnspent(uniqueSh.sublist(i, end))); + } + } catch (e) { + printV('[PIVX] batch unspent fetch failed: $e'); + return List?>.filled(addresses.length, null); + } + + final coinsBySh = >{}; + for (final sh in uniqueSh) { + final owner = ownerBySh[sh]!; + final coins = []; + for (final unspent in unspentBySh[sh] ?? const >[]) { + try { + final coin = BitcoinUnspent.fromJSON(owner, unspent); + coin.isChange = owner.isHidden; + final height = unspent['height'] as int?; + coin.confirmations = (height != null && height > 0) + ? (tip >= height ? tip - height + 1 : 1) + : 0; + coins.add(coin); + } catch (_) {} + } + coinsBySh[sh] = coins; + } + + // Emit each scripthash's coins once. Duplicate address records for the same + // scripthash (transient during discovery) get an empty list so utxos are + // never double-counted. + final emitted = {}; + return List?>.generate(addresses.length, (i) { + final sh = shByIndex[i]; + if (sh.isEmpty || !emitted.add(sh)) return []; + return coinsBySh[sh] ?? []; + }); + } + + /// Custom scripthash: parent's getScriptHash(network) fails for PIVX. + @override + Future fetchTransactionsForAddressType( + Map historiesWithDetails, + BitcoinAddressType type, + ) async { + final addressesByType = + walletAddresses.allAddresses.where((addr) => addr.type == type); + final hiddenAddresses = + addressesByType.where((addr) => addr.isHidden == true); + final receiveAddresses = + addressesByType.where((addr) => addr.isHidden == false); + walletAddresses.hiddenAddresses + .addAll(hiddenAddresses.map((e) => e.address)); + await walletAddresses.saveAddressesInBox(); + await Future.wait(addressesByType.map((addressRecord) async { + final history = await _fetchPivxAddressHistory( + addressRecord, await getCurrentChainTip()); + + if (history.isNotEmpty) { + addressRecord.txCount = history.length; + historiesWithDetails.addAll(history); + + final matchedAddresses = + addressRecord.isHidden ? hiddenAddresses : receiveAddresses; + final isUsedAddressUnderGap = matchedAddresses + .toList() + .indexOf(addressRecord) >= + matchedAddresses.length - + (addressRecord.isHidden + ? ElectrumWalletAddressesBase.defaultChangeAddressesCount + : ElectrumWalletAddressesBase.defaultReceiveAddressesCount); + + if (isUsedAddressUnderGap) { + final prevLength = walletAddresses.allAddresses.length; + + // discover addresses until the gap limit is met + await walletAddresses.discoverAddresses( + matchedAddresses.toList(), + addressRecord.isHidden, + (address) async { + await subscribeForUpdates(); + return _fetchPivxAddressHistory( + address, await getCurrentChainTip()) + .then( + (history) => history.isNotEmpty ? address.address : null); + }, + type: type, + isLegacyDerivation: false, + ); + + final newLength = walletAddresses.allAddresses.length; + + if (newLength > prevLength) { + await fetchTransactionsForAddressType(historiesWithDetails, type); + } + } + } + })); + } + + /// Fetch address history with PIVX scripthash. + Future> _fetchPivxAddressHistory( + BitcoinAddressRecord addressRecord, int? currentHeight) async { + try { + final Map historiesWithDetails = {}; + + final sh = PivxNetwork.computeScriptHash(addressRecord.address); + if (sh.isEmpty) return {}; + + final history = await electrumClient.getHistory(sh); + + if (history.isNotEmpty) { + addressRecord.setAsUsed(); + walletAddresses.clearLockIfMatches( + addressRecord.type, addressRecord.address); + + await Future.wait(history.map((transaction) async { + final txHash = transaction['tx_hash'] as String; + try { + final height = transaction['height'] as int; + final storedTx = transactionHistory.transactions[txHash]; + + if (storedTx != null) { + if (height > 0) { + storedTx.height = height; + // the tx's block itself is the first confirmation so add 1 + if ((currentHeight ?? 0) > 0) { + storedTx.confirmations = currentHeight! - height + 1; + } + storedTx.isPending = storedTx.confirmations == 0; + } + + historiesWithDetails[txHash] = storedTx; + } else { + final tx = await fetchTransactionInfo( + hash: txHash, height: height, retryOnFailure: true); + // z->t receives carry Sapling data that BtcTransaction.fromRaw + // rejects, so fetchTransactionInfo returns null and the receive is + // dropped from history while the balance still credits it. rebuild + // it from the node's decoded verbose JSON instead. + final entry = tx ?? + await _buildPivxIncomingFromVerbose( + txHash, height, currentHeight); + + if (entry != null) { + historiesWithDetails[txHash] = entry; + transactionHistory.addOne(entry); + await transactionHistory.save(); + } + } + } catch (e) { + // one unparseable tx (e.g. an OP_RETURN note output) must not drop + // the whole address's history. skip it, keep the rest, log for diag. + printV('PIVX: skipped tx $txHash in history: $e'); + } + + return Future.value(null); + })); + } + + return historiesWithDetails; + } catch (e) { + printV('PIVX: Error fetching transparent address history'); + return {}; + } + } + + /// Build an incoming history entry from the node's verbose tx JSON when the + /// raw parser can't deserialize the tx (Sapling-bearing z->t receives). + /// Sums outputs paying us; returns null when none do so our own sends never + /// register as a false incoming. A shielded-funded incoming (no transparent + /// vin) counts all our addresses, since an external deshield can land on a + /// change-branch receive address; a tx we funded transparently (our own t->z + /// shield) restricts to non-change addresses so its change isn't miscounted. + Future _buildPivxIncomingFromVerbose( + String txHash, int height, int? currentHeight) async { + try { + // our own z->t send spends our shielded notes; the shielded side records + // that outgoing, so don't also log a transparent incoming for it. + final shieldedStorage = _shieldSyncEngine?.storage; + if (shieldedStorage != null && + shieldedStorage.notes.any((n) => + n.spendingTxid == txHash || n.pendingSpendingTxid == txHash)) { + return null; + } + + final verbose = await electrumClient.getTransactionVerbose(hash: txHash); + if (verbose.isEmpty) return null; + final vout = verbose['vout']; + if (vout is! List) return null; + + // A z->t deshield has zero transparent inputs (funded from the shielded + // pool), so an incoming with no transparent vin that isn't one of our own + // shielded spends (guarded above) is a genuine deshield receive: count + // outputs to ALL our addresses, including a change-branch one. A tx WITH + // transparent inputs could be our own t->z shield whose change lands on a + // hidden address, so restrict to non-change addresses there. + final vin = verbose['vin']; + final hasTransparentInput = vin is List && vin.isNotEmpty; + final matchAddresses = (hasTransparentInput + ? walletAddresses.allAddresses.where((a) => !a.isHidden) + : walletAddresses.allAddresses) + .map((a) => a.address) + .toSet(); + + int received = 0; + for (final out in vout) { + if (out is! Map) continue; + final spk = out['scriptPubKey']; + if (spk is! Map) continue; + final outAddresses = {}; + final list = spk['addresses']; + if (list is List) outAddresses.addAll(list.map((e) => e.toString())); + final single = spk['address']; + if (single != null) outAddresses.add(single.toString()); + if (outAddresses.any(matchAddresses.contains)) { + received += stringDoubleToBitcoinAmount((out['value'] ?? 0).toString()); + } + } + if (received <= 0) return null; + + final confirmations = + (currentHeight != null && height > 0 && currentHeight >= height) + ? currentHeight - height + 1 + : 0; + final time = verbose['time']; + final date = time is int + ? DateTime.fromMillisecondsSinceEpoch(time * 1000) + : DateTime.now(); + + return ElectrumTransactionInfo( + WalletType.pivx, + id: txHash, + height: height, + amount: _pivxMoney(received), + fee: _zeroPivxMoney, + direction: TransactionDirection.incoming, + isPending: height <= 0, + date: date, + confirmations: confirmations, + ); + } catch (e) { + printV('PIVX: verbose incoming fallback failed for $txHash'); + return null; + } + } + + /// PIVX transparent dust threshold based on PIVX Core dustRelayFee + /// of 30,000 zatoshis/kB and a typical 182-byte output spend cost. + @override + BigInt get networkDustAmount => + BigInt.from(PivxFeePolicy.transparentDustThreshold); + + /// Estimate tx size: ~148 B/input, ~34 B/output, ~10 B overhead. + static int estimatedPivxTransactionSize(int inputsCount, int outputsCounts) => + PivxFeePolicy.transparentTxSize(inputsCount, outputsCounts); + + @override + Future updateFeeRates() async { + // PIVX uses a fixed fee policy (see feeRate), and the ElectrumX servers + // don't serve blockchain.estimatefee. The base would fire 3 estimatefee + // requests every sync and every minute for nothing, so no-op it. + } + + @override + int feeRate(TransactionPriority priority) { + // PIVX ElectrumX servers don't serve blockchain.estimatefee, so the base + // rate resolves to 0 and every transparent send builds a zero-fee tx that + // the network rejects. Use PIVX's fixed min-relay-based rate instead. + if (priority is PivxTransactionPriority) { + return priority.feeRate; + } + return PivxFeePolicy.minRelayFeePerKb; + } + + @override + int feeAmountForPriority( + TransactionPriority priority, + int inputsCount, + int outputsCount, { + int? size, + }) => + feeRate(priority) * + (size ?? estimatedPivxTransactionSize(inputsCount, outputsCount)) ~/ + 1000; + + @override + int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount, + {int? size}) => + feeRate * + (size ?? estimatedPivxTransactionSize(inputsCount, outputsCount)) ~/ + 1000; + + static Future create({ + required String mnemonic, + required String password, + required WalletInfo walletInfo, + required DerivationInfo derivationInfo, + required Box unspentCoinsInfo, + required EncryptionFileUtils encryptionFileUtils, + bool isTestnet = false, + String? passphrase, + String? addressPageType, + List? initialAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + }) async { + return PivxWallet( + mnemonic: mnemonic, + password: password, + walletInfo: walletInfo, + derivationInfo: derivationInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: initialAddresses, + initialBalance: initialBalance, + seedBytes: MnemonicBip39.toSeed(mnemonic, passphrase: passphrase), + encryptionFileUtils: encryptionFileUtils, + pivxNetwork: isTestnet ? PivxNetwork.testnet : PivxNetwork.mainnet, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + addressPageType: P2pkhAddressType.p2pkh, + passphrase: passphrase, + ); + } + + static Future open({ + required String name, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required String password, + required EncryptionFileUtils encryptionFileUtils, + bool? isTestnet, + }) async { + final pivxNetwork = (isTestnet ?? walletInfo.network == 'testnet') + ? PivxNetwork.testnet + : PivxNetwork.mainnet; + + final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type); + + ElectrumWalletSnapshot? snp = null; + + try { + snp = await ElectrumWalletSnapshot.load( + encryptionFileUtils, + name, + walletInfo.type, + password, + pivxNetwork, + ); + } catch (e) { + if (!hasKeysFile) rethrow; + } + + final WalletKeysData keysData; + // Migrate old-scheme wallets to the .keys file scheme. + if (!hasKeysFile) { + keysData = WalletKeysData( + mnemonic: snp!.mnemonic, + xPub: snp.xpub, + passphrase: snp.passphrase, + ); + } else { + keysData = await WalletKeysFile.readKeysFile( + name, + walletInfo.type, + password, + encryptionFileUtils, + ); + } + + return PivxWallet( + mnemonic: keysData.mnemonic!, + password: password, + walletInfo: walletInfo, + derivationInfo: await walletInfo.getDerivationInfo(), + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: snp?.addresses, + initialBalance: snp?.balance, + seedBytes: await MnemonicBip39.toSeed(keysData.mnemonic!, + passphrase: keysData.passphrase), + encryptionFileUtils: encryptionFileUtils, + pivxNetwork: pivxNetwork, + initialRegularAddressIndex: snp?.regularAddressIndex, + initialChangeAddressIndex: snp?.changeAddressIndex, + addressPageType: P2pkhAddressType.p2pkh, + passphrase: keysData.passphrase, + ); + } + + @override + Future signMessage(String message, {String? address = null}) async { + int? index; + try { + index = address != null + ? walletAddresses.allAddresses + .firstWhere((element) => element.address == address) + .index + : null; + } catch (_) {} + final HD = index == null ? mainHd : mainHd.childKey(Bip32KeyIndex(index)); + final priv = ECPrivate.fromWif( + WifEncoder.encode(HD.privateKey.raw, netVer: network.wifNetVer), + netVersion: network.wifNetVer, + ); + return priv.signMessage(StringUtils.encode(message)); + } + + /// Shielded (Sapling) addresses start with 'ps1' (mainnet) or + /// 'ptestsapling1' (testnet). + bool _isShieldedAddress(String address) { + final addr = address.toLowerCase().trim(); + return addr.startsWith('ps1') || addr.startsWith('ptestsapling1'); + } + + /// Routes to the Sapling builder when the destination is shielded (ps1...), + /// otherwise the standard transparent path. + @override + Future createTransaction(Object credentials) async { + final transactionCredentials = credentials as BitcoinTransactionCredentials; + final spendFromShielded = + transactionCredentials.coinTypeToSpendFrom == UnspentCoinType.sapling; + + var hasShieldedOutput = false; + var hasTransparentOutput = false; + for (final out in transactionCredentials.outputs) { + final address = out.isParsedAddress ? out.extractedAddress! : out.address; + + if (_isShieldedAddress(address)) { + hasShieldedOutput = true; + } else { + hasTransparentOutput = true; + } + } + + if (hasShieldedOutput && hasTransparentOutput) { + throw Exception( + 'PIVX mixed transparent and shielded outputs are not supported yet.'); + } + + if (spendFromShielded && hasTransparentOutput) { + // z-to-t (deshield): spend shielded notes into a transparent payment + // output with shielded change, built by the Sapling builder. + return await _createShieldedPendingTransaction(transactionCredentials); + } + + if (hasShieldedOutput) { + var source = transactionCredentials.coinTypeToSpendFrom; + final autoSelected = source == UnspentCoinType.any; + if (autoSelected) { + // No explicit source picked: match the shielded destination + // privacy-first: spend shielded notes (z-to-z) when they cover the + // send, otherwise shield transparent funds (t-to-z). + final out = transactionCredentials.outputs.first; + final needed = out.sendAll ? 1 : (out.cryptoAmount.amount.toInt()); + source = shieldedBalance >= needed + ? UnspentCoinType.sapling + : UnspentCoinType.transparent; + } + + // shieldedBalance >= needed ignores the sapling fee, so an exact-balance + // z-to-z can't cover it. when we auto-picked shielded, fall back to + // shielding transparent funds (t-to-z) instead of failing the send. an + // explicit shielded source is respected and surfaces the error. + if (autoSelected && source == UnspentCoinType.sapling) { + try { + return await _createShieldedPendingTransaction( + transactionCredentials, + sourceOverride: UnspentCoinType.sapling, + ); + } catch (e) { + if (!_isInsufficientShieldedFunds(e)) rethrow; + printV( + '[PIVX] shielded funds cannot cover the fee, falling back to t-to-z'); + return await _createShieldedPendingTransaction( + transactionCredentials, + sourceOverride: UnspentCoinType.transparent, + ); + } + } + + return await _createShieldedPendingTransaction( + transactionCredentials, + sourceOverride: source, + ); + } + + // all-transparent send: no shielded output to carry a memo. the memo field + // shows for pivx globally, so strip any entered memo here or it leaks + // on-chain as a public OP_RETURN on the transparent tx. + if (transactionCredentials.outputs.any((o) => o.memo != null)) { + final stripped = BitcoinTransactionCredentials( + transactionCredentials.outputs + .map((o) => OutputInfo( + address: o.address, + sendAll: o.sendAll, + isParsedAddress: o.isParsedAddress, + cryptoAmount: o.cryptoAmount, + fiatAmount: o.fiatAmount, + note: o.note, + extractedAddress: o.extractedAddress, + memo: null, + extra: o.extra, + )) + .toList(), + priority: transactionCredentials.priority, + feeRate: transactionCredentials.feeRate, + coinTypeToSpendFrom: transactionCredentials.coinTypeToSpendFrom, + payjoinUri: transactionCredentials.payjoinUri, + ); + return await super.createTransaction(stripped); + } + return await super.createTransaction(credentials); + } + + /// Pending shielded transaction built by the Sapling builder (z-to-z, z-to-t + /// deshield, or t-to-z shield). + Future _createShieldedPendingTransaction( + BitcoinTransactionCredentials credentials, { + UnspentCoinType? sourceOverride, + }) async { + if (credentials.outputs.length != 1) { + throw Exception( + 'Shielded transactions currently support only single outputs'); + } + + final output = credentials.outputs.first; + final toAddress = + output.isParsedAddress ? output.extractedAddress! : output.address; + final isSendAll = output.sendAll; + final transparentDestination = !_isShieldedAddress(toAddress); + // only a shielded destination carries a memo; drop a stale one for a + // transparent recipient so the build doesn't reject it. + final memo = transparentDestination ? null : output.memo; + + final coinType = sourceOverride ?? credentials.coinTypeToSpendFrom; + if (coinType != UnspentCoinType.sapling) { + // t-to-z (shield): spend transparent UTXOs into the Sapling output. + final built = await _buildShieldTransactionResult( + toAddress: toAddress, + requestedAmount: isSendAll ? null : output.cryptoAmount.amount.toInt(), + isSendAll: isSendAll, + memo: memo, + ); + return PendingPivxShieldedTransaction( + result: built.result, + electrumClient: electrumClient, + amount: built.amount, + fee: built.fee, + onCommit: (tx) async { + // record the shield as an outgoing "Sent" so the transparent change + // isn't shown as a received utxo. nothing else records this send, so + // without it the change utxo reads as an incoming payment. + try { + await _recordPendingShieldedOutgoing( + txid: built.result.txId, + amount: built.amount, + fee: built.fee, + toAddress: toAddress, + route: 't-to-z', + ); + } catch (_) {} + try { + await updateAllUnspents(); + await updateBalance(); + } catch (_) {} + try { + await syncShielded(); + } catch (e) { + printV( + '[PIVX Sapling] Shielded post-broadcast sync failed: ${sanitizeShieldSyncError(e)}'); + } + }, + ); + } + + await initializeSapling(); + await _ensureShieldSyncEngineInitialized(); + + final amount = isSendAll + ? _shieldedSendAllAmount(transparentDestination: transparentDestination) + : output.cryptoAmount.amount.toInt(); + + final hasShieldedFunds = shieldedBalance >= amount; + + if (hasShieldedFunds) { + final result = await createShieldedTransaction( + toAddress: toAddress, + amount: amount, + memo: memo, + useShieldedInputs: true, + spendAllShieldedInputs: isSendAll, + ); + + return PendingPivxShieldedTransaction( + result: result, + electrumClient: electrumClient, + amount: amount, + fee: result.fee, + onBroadcastFailure: () => releaseReservedShieldedNotes(result.txId), + onCommit: (tx) async { + try { + await _recordPendingShieldedOutgoing( + txid: result.txId, + amount: amount, + fee: result.fee, + toAddress: toAddress, + route: transparentDestination ? 'z-to-t' : 'z-to-z', + ); + + if (result.spentNullifiers.isNotEmpty) { + await _shieldSyncEngine!.storage.markPendingSpentByNullifiers( + result.spentNullifiers, + result.txId, + ); + await _reconcileShieldedBalance(); + } else { + await updateBalance(); + } + } finally { + // The broadcast already succeeded, so the build-time reservation has + // done its job and must be dropped even if the bookkeeping above + // threw, otherwise the notes stay locked for the session. The next + // sync reconciles authoritative on-chain spend state. + releaseReservedShieldedNotes(result.txId); + } + + try { + await syncShielded(); + } catch (e) { + printV( + '[PIVX Sapling] Shielded post-broadcast sync failed: ${sanitizeShieldSyncError(e)}'); + } + }, + ); + } + + throw Exception('Insufficient shielded balance.'); + } + + int _shieldedSendAllAmount({bool transparentDestination = false}) { + final notes = _shieldSyncEngine!.storage.spendableNotesAt( + chainHeight: _shieldSyncEngine!.storage.lastSyncedHeight, + ); + if (notes.isEmpty) { + throw Exception('No spendable shielded notes available.'); + } + + final total = notes.fold(0, (sum, note) => sum + note.value); + final fee = PivxFeePolicy.saplingFee( + saplingInputs: notes.length, + saplingOutputs: transparentDestination ? 0 : 1, + transparentOutputs: transparentDestination ? 1 : 0, + ); + final amount = total - fee; + final dustFloor = transparentDestination + ? PivxFeePolicy.transparentDustThreshold + : PivxFeePolicy.shieldedDustThreshold; + if (amount < dustFloor) { + throw Exception('Insufficient shielded balance after PIVX fee.'); + } + + return amount; + } + + /// PIVX coinstake detection (primitives/transaction.cpp): non-empty vin, + /// first vin has a prevout, first vout empty, >=2 outputs. Coinstake outputs + /// have different maturity rules, so this matters for balance. + static bool isCoinstakeTransaction(Map tx) { + final vins = tx['vin'] as List?; + final vouts = tx['vout'] as List?; + + if (vins == null || vins.isEmpty) return false; + if (vouts == null || vouts.length < 2) return false; + + final firstVin = vins.first as Map?; + if (firstVin == null) return false; + final txid = firstVin['txid']; + if (txid == null || txid == '') return false; + + final firstVout = vouts.first as Map?; + if (firstVout == null) return false; + final value = firstVout['value']; + if (value != 0 && value != 0.0) return false; + + return true; + } + + /// Coinbase detection: single vin with a null prevout. + static bool isCoinbaseTransaction(Map tx) { + final vins = tx['vin'] as List?; + if (vins == null || vins.length != 1) return false; + + final firstVin = vins.first as Map?; + if (firstVin == null) return false; + + final coinbase = firstVin['coinbase']; + return coinbase != null; + } +} + +/// Built t-to-z shield transaction with its planned amount and fee. +class _BuiltShieldTransaction { + _BuiltShieldTransaction({ + required this.result, + required this.amount, + required this.fee, + }); + + final SaplingTransactionResult result; + final int amount; + final int fee; +} diff --git a/cw_pivx/lib/src/pivx_wallet_addresses.dart b/cw_pivx/lib/src/pivx_wallet_addresses.dart new file mode 100644 index 0000000000..058a2ad2d8 --- /dev/null +++ b/cw_pivx/lib/src/pivx_wallet_addresses.dart @@ -0,0 +1,89 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/payment_uris.dart'; +import 'package:cw_core/receive_page_option.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_pivx/src/pivx_receive_page_options.dart'; +import 'package:mobx/mobx.dart'; + +part 'pivx_wallet_addresses.g.dart'; + +/// PIVX address management. BIP44 coin type 119 +/// (m/44'/119'/account'/change/index). Address prefixes: 'D' P2PKH, '8' P2SH, +/// 'S' staking, 'ps1' Sapling. No native SegWit; follows the Electrum pattern. +class PivxWalletAddresses = PivxWalletAddressesBase with _$PivxWalletAddresses; + +abstract class PivxWalletAddressesBase extends ElectrumWalletAddresses + with Store { + PivxWalletAddressesBase( + WalletInfo walletInfo, { + required super.mainHdByType, + required super.sideHdByType, + required super.legacyMainHd, + required super.legacySideHd, + required super.network, + required super.isHardwareWallet, + super.initialAddresses, + super.initialRegularAddressIndex, + super.initialChangeAddressIndex, + super.initialAddressPageType, + }) : super(walletInfo); + + @override + String getAddress({ + required int index, + required Bip32Slip10Secp256k1 hd, + BitcoinAddressType? addressType, + }) => + generateP2PKHAddress(hd: hd, index: index, network: network); + + /// mirrors PivxWallet.saplingEnabled (pushed via [setSaplingEnabled]); gates + /// whether the shielded receive option shows. plain field (stable by the time + /// Receive opens), no mobx codegen needed. + bool saplingEnabled = true; + + void setSaplingEnabled(bool value) => saplingEnabled = value; + + /// receive-page address types: transparent + shielded, or transparent-only + /// when Sapling is unavailable. replaces the base single "mainnet" option that + /// left the type picker hidden. + @override + List get receivePageOptions => saplingEnabled + ? PivxReceivePageOption.all + : const [PivxReceivePageOption.transparent]; + + @override + PaymentURI getPaymentUri(String amount) => + PivxURI(amount: amount, address: address); + + /// Selected shielded address (display only). + @observable + String? selectedShieldedAddress; + + @override + @computed + String get address { + if (selectedShieldedAddress != null) { + return selectedShieldedAddress!; + } + return super.address; + } + + /// Revert to the transparent address. + void clearShieldedSelection() { + selectedShieldedAddress = null; + } + + @override + set address(String addr) { + // Sapling ('ps') addresses aren't stored in the regular address list. + if (addr.startsWith('ps1') || addr.startsWith('ps')) { + selectedShieldedAddress = addr; + return; + } + selectedShieldedAddress = null; + super.address = addr; + } +} diff --git a/cw_pivx/lib/src/pivx_wallet_creation_credentials.dart b/cw_pivx/lib/src/pivx_wallet_creation_credentials.dart new file mode 100644 index 0000000000..751e09fed1 --- /dev/null +++ b/cw_pivx/lib/src/pivx_wallet_creation_credentials.dart @@ -0,0 +1,53 @@ +import 'package:cw_core/wallet_credentials.dart'; +import 'package:cw_core/wallet_info.dart'; + +class PivxNewWalletCredentials extends WalletCredentials { + PivxNewWalletCredentials({ + required String name, + WalletInfo? walletInfo, + String? password, + String? passphrase, + this.mnemonic, + }) : super( + name: name, + walletInfo: walletInfo, + password: password, + passphrase: passphrase, + ); + + final String? mnemonic; +} + +class PivxRestoreWalletFromSeedCredentials extends WalletCredentials { + PivxRestoreWalletFromSeedCredentials({ + required String name, + required String password, + required this.mnemonic, + WalletInfo? walletInfo, + String? passphrase, + int? height, + }) : super( + name: name, + password: password, + walletInfo: walletInfo, + passphrase: passphrase, + height: height, + ); + + final String mnemonic; +} + +class PivxRestoreWalletFromWIFCredentials extends WalletCredentials { + PivxRestoreWalletFromWIFCredentials({ + required String name, + required String password, + required this.wif, + WalletInfo? walletInfo, + }) : super( + name: name, + password: password, + walletInfo: walletInfo, + ); + + final String wif; +} diff --git a/cw_pivx/lib/src/pivx_wallet_service.dart b/cw_pivx/lib/src/pivx_wallet_service.dart new file mode 100644 index 0000000000..38ff3cf284 --- /dev/null +++ b/cw_pivx/lib/src/pivx_wallet_service.dart @@ -0,0 +1,176 @@ +import 'dart:io'; + +import 'package:bip39/bip39.dart'; +import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/unspent_coins_info.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_service.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_pivx/cw_pivx.dart'; +import 'package:hive/hive.dart'; + +class PivxWalletService extends WalletService< + PivxNewWalletCredentials, + PivxRestoreWalletFromSeedCredentials, + PivxRestoreWalletFromWIFCredentials, + PivxNewWalletCredentials> { + PivxWalletService(this.unspentCoinsInfoSource, this.isDirect); + + final Box unspentCoinsInfoSource; + final bool isDirect; + + @override + WalletType getType() => WalletType.pivx; + + @override + Future isWalletExit(String name) async => + File(await pathForWallet(name: name, type: getType())).existsSync(); + + @override + Future create(credentials, {bool? isTestnet}) async { + final strength = credentials.seedPhraseLength == 24 ? 256 : 128; + credentials.walletInfo!.network = + (isTestnet ?? false) ? 'testnet' : 'mainnet'; + + final wallet = await PivxWalletBase.create( + mnemonic: + credentials.mnemonic ?? MnemonicBip39.generate(strength: strength), + password: credentials.password!, + walletInfo: credentials.walletInfo!, + derivationInfo: await credentials.walletInfo!.getDerivationInfo(), + unspentCoinsInfo: unspentCoinsInfoSource, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + passphrase: credentials.passphrase, + isTestnet: isTestnet ?? false, + ); + await wallet.save(); + await wallet.init(); + + return wallet; + } + + @override + Future openWallet(String name, String password) async { + final walletInfo = await WalletInfo.get(name, getType()); + if (walletInfo == null) { + throw Exception('Wallet not found'); + } + try { + final wallet = await PivxWalletBase.open( + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + isTestnet: walletInfo.network == 'testnet', + ); + await wallet.init(); + saveBackup(name); + return wallet; + } catch (_) { + await restoreWalletFilesFromBackup(name); + final wallet = await PivxWalletBase.open( + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + isTestnet: walletInfo.network == 'testnet', + ); + await wallet.init(); + return wallet; + } + } + + @override + Future remove(String wallet) async { + File(await pathForWalletDir(name: wallet, type: getType())) + .delete(recursive: true); + final walletInfo = await WalletInfo.get(wallet, getType()); + if (walletInfo == null) { + throw Exception('Wallet not found'); + } + await WalletInfo.delete(walletInfo); + + final unspentCoinsToDelete = unspentCoinsInfoSource.values + .where((unspentCoin) => unspentCoin.walletId == walletInfo.id) + .toList(); + + final keysToDelete = + unspentCoinsToDelete.map((unspentCoin) => unspentCoin.key).toList(); + + if (keysToDelete.isNotEmpty) { + await unspentCoinsInfoSource.deleteAll(keysToDelete); + } + } + + @override + Future rename( + String currentName, String password, String newName) async { + final currentWalletInfo = await WalletInfo.get(currentName, getType()); + if (currentWalletInfo == null) { + throw Exception('Wallet not found'); + } + final currentWallet = await PivxWalletBase.open( + password: password, + name: currentName, + walletInfo: currentWalletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + isTestnet: currentWalletInfo.network == 'testnet', + ); + + await currentWallet.renameWalletFiles(newName); + await saveBackup(newName); + + final newWalletInfo = currentWalletInfo; + newWalletInfo.id = WalletBase.idFor(newName, getType()); + newWalletInfo.name = newName; + + await newWalletInfo.save(); + } + + @override + Future restoreFromHardwareWallet( + PivxNewWalletCredentials credentials) { + throw UnimplementedError( + "Restoring a PIVX wallet from a hardware wallet is not yet supported!"); + } + + @override + Future restoreFromKeys( + PivxRestoreWalletFromWIFCredentials credentials, + {bool? isTestnet}) { + throw UnimplementedError( + "PIVX wallets restore from a seed phrase; WIF key import is not supported!"); + } + + @override + Future restoreFromSeed( + PivxRestoreWalletFromSeedCredentials credentials, { + bool? isTestnet, + }) async { + if (!validateMnemonic(credentials.mnemonic)) { + throw Exception('Invalid PIVX mnemonic'); + } + credentials.walletInfo!.network = + (isTestnet ?? false) ? 'testnet' : 'mainnet'; + + final wallet = await PivxWalletBase.create( + password: credentials.password!, + mnemonic: credentials.mnemonic, + walletInfo: credentials.walletInfo!, + derivationInfo: await credentials.walletInfo!.getDerivationInfo(), + unspentCoinsInfo: unspentCoinsInfoSource, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + passphrase: credentials.passphrase, + isTestnet: isTestnet ?? false, + ); + await wallet.save(); + await wallet.init(); + return wallet; + } +} diff --git a/cw_pivx/lib/src/sapling/native_sapling_key_manager.dart b/cw_pivx/lib/src/sapling/native_sapling_key_manager.dart new file mode 100644 index 0000000000..d9be27c368 --- /dev/null +++ b/cw_pivx/lib/src/sapling/native_sapling_key_manager.dart @@ -0,0 +1,56 @@ +/// Native Sapling key manager backed by the Rust FFI bindings. + +import 'dart:typed_data'; +import 'package:cw_pivx/src/sapling/sapling_constants.dart'; +import 'package:cw_pivx/src/sapling/sapling_ffi.dart' as ffi; + +/// Core key operations; the factories wrap this into the full wallet interface. +class NativeSaplingKeyManager { + final ffi.SaplingKeys _keys; + final bool _isTestnet; + int _nextDiversifierIndex = 0; + + NativeSaplingKeyManager._(this._keys, this._isTestnet); + + static Future fromSeed( + Uint8List seed, { + bool isTestnet = false, + }) async { + final keys = ffi.SaplingKeys.fromSeed(seed, isTestnet: isTestnet); + return NativeSaplingKeyManager._(keys, isTestnet); + } + + Future getDefaultAddress() async { + return _keys.getDefaultAddress(); + } + + Future deriveAddress(int index) async { + return _keys.deriveAddress(index); + } + + Future getNextAddress() async { + final address = _keys.deriveAddress(_nextDiversifierIndex); + _nextDiversifierIndex++; + return address; + } + + Future getFullViewingKey() async { + return _keys.getViewingKey(); + } + + bool validateAddress(String address) { + return ffi.validateAddress(address, isTestnet: _isTestnet); + } + + bool get isTestnet => _isTestnet; + + String get paymentAddressHrp => _isTestnet + ? PivxSaplingNetwork.testnetPaymentAddressHrp + : PivxSaplingNetwork.mainnetPaymentAddressHrp; + + void dispose() { + _keys.dispose(); + } + + ffi.SaplingKeys get nativeKeys => _keys; +} diff --git a/cw_pivx/lib/src/sapling/native_shield_sync_engine.dart b/cw_pivx/lib/src/sapling/native_shield_sync_engine.dart new file mode 100644 index 0000000000..f328df39aa --- /dev/null +++ b/cw_pivx/lib/src/sapling/native_shield_sync_engine.dart @@ -0,0 +1,21 @@ +/// Owns the native Sapling sync engine handle (Rust FFI). +/// +/// The scan loop lives in [ShieldSyncEngineWrapper]; this type only constructs +/// and disposes the underlying [ffi.SaplingSyncEngine] and exposes its handle. + +import 'package:cw_pivx/src/sapling/sapling_ffi.dart' as ffi; + +class NativeShieldSyncEngine { + final ffi.SaplingSyncEngine _engine; + + NativeShieldSyncEngine._(this._engine); + + factory NativeShieldSyncEngine({bool isTestnet = false}) => + NativeShieldSyncEngine._(ffi.SaplingSyncEngine(isTestnet: isTestnet)); + + ffi.SaplingSyncEngine get nativeEngine => _engine; + + int get handle => _engine.handle; + + void dispose() => _engine.dispose(); +} diff --git a/cw_pivx/lib/src/sapling/pivx_sapling_electrumx.dart b/cw_pivx/lib/src/sapling/pivx_sapling_electrumx.dart new file mode 100644 index 0000000000..bb5a40bfe3 --- /dev/null +++ b/cw_pivx/lib/src/sapling/pivx_sapling_electrumx.dart @@ -0,0 +1,2113 @@ +/// PIVX Sapling ElectrumX API client: type-safe access to Sapling-specific RPCs. +/// +/// - `blockchain.sapling.capabilities`: probe v1 RPC contract metadata +/// - `blockchain.sapling.get_block_range`: v1 block-range envelopes +/// - `blockchain.sapling.get_nullifier_status`: nullifier spent status +/// - `blockchain.sapling.get_commitment_info`: commitment details +/// - `blockchain.sapling.get_best_anchor`: current anchor metadata +/// - `blockchain.sapling.get_witness`: anchor-bound Merkle witness +/// - legacy aliases remain as fallbacks until default nodes expose v1 metadata +/// +/// Activation heights: mainnet 2,700,500, testnet 201. + +import 'dart:async'; +import 'dart:typed_data'; +import 'package:convert/convert.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_pivx/src/sapling/sapling_ffi.dart' as sapling_ffi; + +/// Locally recomputes the Sapling Merkle root for a witness and compares it +/// to the expected anchor. Returns true on match, false on a clean mismatch, +/// and throws when verification itself cannot be performed. +typedef WitnessRootVerifier = bool Function({ + required String witnessHex, + required String cmuHex, + required String anchorHex, + required int position, +}); + +int? _optionalInt(Object? value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + +String? _optionalString(Object? value) { + if (value == null) return null; + final text = value.toString(); + return text.isEmpty ? null : text; +} + +/// Reverse the byte order of a 32-byte (64 hex char) value. +/// +/// PIVX v1 ElectrumX servers advertise `hex_byte_order: "display"`, emitting +/// every 32-byte value (cmu, epk, anchor, nullifier, cv, rk) big-endian via +/// uint256 GetHex. The native Sapling crypto (`Anchor::from_bytes`, +/// `ExtractedNoteCommitment::from_bytes`, jubjub `AffinePoint::from_bytes`, +/// nullifier matching) works in little-endian serialization order, so every +/// 32-byte value must be reversed at the crypto boundary when the node uses +/// display order, including cmu and epk on the trial-decryption/receive path. +/// The only contract exception is witness path nodes. Variable-length blobs +/// (ciphertexts, proofs) are raw wire bytes and are never reversed. +String reverseSaplingHexBytes(String hexValue) { + final buffer = StringBuffer(); + for (var offset = hexValue.length; offset >= 2; offset -= 2) { + buffer.write(hexValue.substring(offset - 2, offset)); + } + return buffer.toString(); +} + +/// v1 get_block_range error types that mean "not ready yet, retry" rather than +/// a hard failure. Never advance the synced height past these. +const Set _retryableRangeErrorTypes = { + 'index_incomplete', + 'index_not_ready', + 'backend_timeout', +}; + +String? _rangeErrorType(Object? error) { + if (error == null) return null; + if (error is Map) { + return _optionalString(error['type']) ?? _optionalString(error['code']); + } + if (error is String) { + return error.isEmpty ? null : error; + } + return null; +} + +const String _v1LiveProbeHex32 = + '0000000000000000000000000000000000000000000000000000000000000000'; + +class _BatchResult { + final List blocks; + final int startHeight; + final int endHeight; + final Map blockHashes; + _BatchResult(this.blocks, this.startHeight, this.endHeight, this.blockHashes); +} + +class SaplingRpcException implements Exception { + final String message; + final Object? cause; + + SaplingRpcException(this.message, [this.cause]); + + @override + String toString() => cause == null ? message : '$message: $cause'; +} + +/// A retryable get_block_range failure (indexer lag / backend timeout). The +/// sync loop already retries on any [SaplingRpcException]; this distinct +/// subtype marks the "not ready yet" case so callers never treat it as a hard +/// malformed-data error and never advance the synced height past it. +class SaplingRetryableRangeException extends SaplingRpcException { + SaplingRetryableRangeException(super.message, [super.cause]); +} + +/// capability probe that failed on an incomplete/garbled caps payload (worth +/// retrying), not a definitive rejection (wrong network, half-upgraded v1, +/// unsupported node). carries the cause so the real message surfaces once +/// retries are exhausted. +class _RetryableCapabilityProbe implements Exception { + _RetryableCapabilityProbe(this.cause); + final Object cause; + @override + String toString() => 'RetryableCapabilityProbe: $cause'; +} + +// cap on a single get_block_range. only trips when the node keeps the socket +// alive (keep-alive ping still answers, no disconnect) but stalls on the range +// query. call() has no timeout, so this stops that stall wedging the sync. +const Duration kSaplingBlockRangeFetchTimeout = Duration(seconds: 30); + +class SaplingRpcCapabilities { + static const String v1ContractId = 'pivx.sapling.electrumx.v1'; + static const String legacyBlockRangeContractId = 'legacy.block_range'; + + final bool supportsBlockRange; + final bool supportsGlobalOutputPositions; + final bool supportsBestAnchor; + final bool supportsWitness; + final bool supportsBlockHashes; + final bool supportsStructuredErrors; + final String? network; + final int? activationHeight; + final int? maxBlockRange; + final String? contract; + final String? serverVersion; + final String? pivxCoreVersion; + final Set methods; + + /// Raw `hex_byte_order` advertised by the node ("display" or "serialization"). + final String? hexByteOrder; + + /// Node can serve canonical Merkle witnesses for shielded spends. True only + /// when `features.canonical_witnesses` is set AND a witness backend is wired + /// up (`witness_backend` present). Shielded sends are gated on this. + final bool canonicalWitnesses; + + /// Node uses consensus (block-anchored) anchors. + final bool consensusAnchors; + + /// Raw `index_status` block (ready/state/db_height/daemon_height/lag/...). + final Map? indexStatus; + + /// Structured get_block_range error types the node may return. + final List rangeErrorTypes; + + /// node serves the sparse active-height index + /// (`blockchain.sapling.get_active_heights`); lets a restore skip empty block + /// windows instead of scanning every 100-block range. + final bool supportsActiveHeights; + + /// Max heights the node returns per get_active_heights call + /// (`active_heights_max_limit`); null when not advertised. + final int? activeHeightsMaxLimit; + + /// node guarantees `db_height` advances past a block only once its Sapling + /// data is committed and queryable (no committed-but-empty window). when true, + /// scan right at db_height; when false, stay a small margin behind. + final bool supportsConsistentDbHeight; + + /// node exposes get_mempool: unconfirmed Sapling outputs for trial-decryption, + /// so incoming shielded shows at 0-conf instead of waiting a block. + final bool supportsMempool; + + /// node exposes mempool.subscribe: push the mempool envelope on change, so the + /// wallet swaps its poll for one subscription per session. + final bool supportsMempoolSubscribe; + + static const Set requiredV1Methods = { + 'blockchain.sapling.get_block_range', + 'blockchain.sapling.get_best_anchor', + 'blockchain.sapling.get_witness', + 'blockchain.sapling.get_nullifier_status', + 'blockchain.sapling.get_commitment_info', + }; + + const SaplingRpcCapabilities({ + required this.supportsBlockRange, + required this.supportsGlobalOutputPositions, + required this.supportsBestAnchor, + required this.supportsWitness, + this.supportsBlockHashes = false, + this.supportsStructuredErrors = false, + this.network, + this.activationHeight, + this.maxBlockRange, + this.contract, + this.serverVersion, + this.pivxCoreVersion, + this.methods = const {}, + this.hexByteOrder, + this.canonicalWitnesses = false, + this.consensusAnchors = false, + this.indexStatus, + this.rangeErrorTypes = const [], + this.supportsActiveHeights = false, + this.activeHeightsMaxLimit, + this.supportsConsistentDbHeight = false, + this.supportsMempool = false, + this.supportsMempoolSubscribe = false, + }); + + /// The node emits 32-byte hex (cmu / anchor / nullifier) in big-endian + /// display order, so those values must be reversed at the crypto boundary. + bool get usesDisplayByteOrder => hexByteOrder?.toLowerCase() == 'display'; + + /// Flushed Sapling index height (`index_status.db_height`). This is the + /// ceiling for get_block_range: `to <= db_height` is always served, `to > + /// db_height` returns index_incomplete/index_not_ready. Null on legacy nodes + /// that don't report an index status. + int? get indexHeight => _optionalInt(indexStatus?['db_height']); + + /// The daemon's chain tip as seen by the node (`index_status.daemon_height`), + /// i.e. the true header tip. Use this for confirmation counts; it can lead + /// the Sapling index by a small processing window. Null when not reported. + int? get daemonHeight => _optionalInt(indexStatus?['daemon_height']); + + factory SaplingRpcCapabilities.fromJson(Map json) { + final methodList = {}; + final rawMethods = json['methods'] as List? ?? + json['supported_methods'] as List?; + if (rawMethods != null) { + methodList.addAll(rawMethods.map((e) => e.toString())); + } + final aliases = json['aliases']; + if (aliases is Map) { + methodList.addAll(aliases.keys.map((e) => e.toString())); + for (final value in aliases.values) { + if (value is List) { + methodList.addAll(value.map((e) => e.toString())); + } else if (value != null) { + methodList.add(value.toString()); + } + } + } else if (aliases is List) { + methodList.addAll(aliases.map((e) => e.toString())); + } + final rawFeatures = json['features']; + final features = rawFeatures is Map ? rawFeatures : null; + final rawRangeFormat = json['range_response_format']; + final rangeFormat = rawRangeFormat is Map ? rawRangeFormat : null; + final network = _optionalString(json['network']); + final activationHeight = _optionalInt(json['sapling_activation_height']) ?? + _optionalInt(json['activation_height']); + final witnessBackend = _optionalString(json['witness_backend']); + final indexStatusRaw = json['index_status']; + final rangeErrorTypesRaw = json['range_error_types']; + + bool hasMethod(String name) => methodList.contains(name); + + return SaplingRpcCapabilities( + supportsBlockRange: hasMethod('blockchain.sapling.get_block_range') || + json['supports_block_range'] == true, + supportsGlobalOutputPositions: json['global_output_positions'] == true || + json['supports_global_output_positions'] == true || + features?['global_output_positions'] == true || + rangeFormat?['global_output_positions'] == true, + supportsBestAnchor: hasMethod('blockchain.sapling.get_best_anchor') || + hasMethod('blockchain.sapling.get_tree_state') || + json['supports_best_anchor'] == true, + supportsWitness: hasMethod('blockchain.sapling.get_witness') || + json['supports_witness'] == true, + supportsBlockHashes: json['block_hashes'] == true || + json['supports_block_hashes'] == true || + features?['block_hashes'] == true || + rangeFormat?['block_hashes'] == true, + supportsStructuredErrors: json['structured_errors'] == true || + json['supports_structured_errors'] == true || + features?['structured_errors'] == true, + network: network, + activationHeight: activationHeight, + maxBlockRange: _optionalInt(json['max_block_range']) ?? + _optionalInt(json['max_range_size']), + contract: _optionalString(json['contract']) ?? + _optionalString(json['contract_id']), + serverVersion: _optionalString(json['server_version']) ?? + _optionalString(json['electrumx_version']), + pivxCoreVersion: _optionalString(json['pivx_core_version']) ?? + _optionalString(json['core_version']), + methods: methodList, + hexByteOrder: _optionalString(json['hex_byte_order']), + canonicalWitnesses: + features?['canonical_witnesses'] == true && witnessBackend != null, + consensusAnchors: json['consensus_anchors'] == true, + indexStatus: indexStatusRaw is Map + ? Map.from(indexStatusRaw) + : null, + rangeErrorTypes: rangeErrorTypesRaw is List + ? rangeErrorTypesRaw.map((e) => e.toString()).toList(growable: false) + : const [], + supportsActiveHeights: + hasMethod('blockchain.sapling.get_active_heights') || + hasMethod('sapling.get_active_heights') || + json['supports_active_height_index'] == true || + features?['active_height_index'] == true || + features?['supports_active_height_index'] == true, + activeHeightsMaxLimit: _optionalInt(json['active_heights_max_limit']) ?? + _optionalInt(features?['active_heights_max_limit']), + supportsConsistentDbHeight: json['consistent_db_height'] == true || + features?['consistent_db_height'] == true, + supportsMempool: hasMethod('blockchain.sapling.get_mempool') || + hasMethod('sapling.get_mempool') || + json['supports_mempool'] == true || + features?['supports_mempool'] == true, + supportsMempoolSubscribe: + hasMethod('blockchain.sapling.mempool.subscribe') || + hasMethod('sapling.mempool.subscribe') || + json['supports_mempool_subscribe'] == true || + features?['supports_mempool_subscribe'] == true, + ); + } + + bool get advertisesV1Contract => contract?.toLowerCase() == v1ContractId; + + bool get supportsV1ReleaseContract => + advertisesV1Contract && + supportsBlockRange && + supportsGlobalOutputPositions && + supportsBestAnchor && + supportsWitness && + supportsBlockHashes && + supportsStructuredErrors && + methods.containsAll(requiredV1Methods); + + bool get isLegacyBlockRangeOnly => contract == legacyBlockRangeContractId; + + static SaplingRpcCapabilities legacyBlockRangeOnly() => + const SaplingRpcCapabilities( + supportsBlockRange: true, + supportsGlobalOutputPositions: false, + supportsBestAnchor: false, + supportsWitness: false, + contract: legacyBlockRangeContractId, + ); +} + +class SaplingActivation { + static const int mainnet = 2700500; + static const int testnet = 201; +} + +/// Result from get_nullifier_status RPC. +class NullifierStatus { + final bool spent; + + final String? txid; + + final int? height; + + NullifierStatus({ + required this.spent, + this.txid, + this.height, + }); + + factory NullifierStatus.fromJson(Map json) { + return NullifierStatus( + spent: json['spent'] as bool? ?? false, + txid: json['txid'] as String?, + height: json['height'] as int?, + ); + } +} + +/// Result from get_commitment_info RPC. +class CommitmentInfo { + final bool exists; + + final String? txid; + + final int? height; + + final int? index; + + CommitmentInfo({ + required this.exists, + this.txid, + this.height, + this.index, + }); + + factory CommitmentInfo.fromJson(Map json) { + return CommitmentInfo( + exists: json['exists'] as bool? ?? false, + txid: json['txid'] as String?, + height: json['height'] as int?, + index: json['index'] as int?, + ); + } +} + +class SaplingShieldedOutput { + /// Note commitment (cmu), 32 bytes hex. + final String cmu; + + /// Ephemeral public key, 32 bytes hex. + final String epk; + + /// Encrypted note ciphertext, 580 bytes hex (1160 hex chars). + final String ciphertext; + + /// Value commitment (cv), 32 bytes hex. + final String cv; + + /// Outgoing ciphertext, 80 bytes hex (160 hex chars). + final String outCiphertext; + + /// Canonical global Sapling commitment tree position, if returned by server. + final int? globalPosition; + + SaplingShieldedOutput({ + required this.cmu, + required this.epk, + required this.ciphertext, + required this.cv, + required this.outCiphertext, + this.globalPosition, + }); + + factory SaplingShieldedOutput.fromJson(Map json) { + return SaplingShieldedOutput( + cmu: json['cmu'] as String, + epk: json['epk'] as String, + ciphertext: json['ciphertext'] as String, + cv: json['cv'] as String, + outCiphertext: json['out_ciphertext'] as String, + globalPosition: _optionalInt(json['global_position']) ?? + _optionalInt(json['tree_position']) ?? + _optionalInt(json['position']) ?? + _optionalInt(json['index']), + ); + } + + Uint8List get cmuBytes => Uint8List.fromList(hex.decode(cmu)); + + Uint8List get epkBytes => Uint8List.fromList(hex.decode(epk)); + + Uint8List get ciphertextBytes => Uint8List.fromList(hex.decode(ciphertext)); + + Uint8List get cvBytes => Uint8List.fromList(hex.decode(cv)); + + Uint8List get outCiphertextBytes => + Uint8List.fromList(hex.decode(outCiphertext)); +} + +/// Result from get_outputs_by_height RPC. +class SaplingOutputsResult { + final int startHeight; + + final int endHeight; + + final int totalOutputs; + + final List outputs; + + /// True when results were truncated by the limit. + final bool truncated; + + SaplingOutputsResult({ + required this.startHeight, + required this.endHeight, + required this.totalOutputs, + required this.outputs, + required this.truncated, + }); + + factory SaplingOutputsResult.fromJson(Map json) { + final outputsList = (json['outputs'] as List?) + ?.map((e) => + SaplingShieldedOutput.fromJson(e as Map)) + .toList() ?? + []; + + return SaplingOutputsResult( + startHeight: json['start_height'] as int, + endHeight: json['end_height'] as int, + totalOutputs: json['total_outputs'] as int? ?? outputsList.length, + outputs: outputsList, + truncated: json['truncated'] as bool? ?? false, + ); + } +} + +class SaplingBlockRangeResult { + final int startHeight; + final int endHeight; + final List blocks; + final Map blockHashes; + + SaplingBlockRangeResult({ + required this.startHeight, + required this.endHeight, + required this.blocks, + this.blockHashes = const {}, + }); +} + +class SaplingSpend { + /// Nullifier being revealed (marks a note as spent). + final String nullifier; + + final String cv; + + /// Anchor used for the spend proof (Merkle tree root). + final String anchor; + + final String rk; + + SaplingSpend({ + required this.nullifier, + required this.cv, + required this.anchor, + required this.rk, + }); + + factory SaplingSpend.fromJson(Map json) { + return SaplingSpend( + nullifier: json['nullifier'] as String, + cv: json['cv'] as String, + anchor: json['anchor'] as String, + rk: json['rk'] as String, + ); + } + + Uint8List get nullifierBytes => Uint8List.fromList(hex.decode(nullifier)); + + Uint8List get cvBytes => Uint8List.fromList(hex.decode(cv)); + + Uint8List get anchorBytes => Uint8List.fromList(hex.decode(anchor)); + + Uint8List get rkBytes => Uint8List.fromList(hex.decode(rk)); +} + +/// A Sapling transaction from get_block_range. +class SaplingTransaction { + final String txid; + + /// Shielded outputs (new notes being created). + final List outputs; + + /// Shielded spends (notes being spent, nullifiers revealed). + final List spends; + + /// Unix epoch the tx entered the mempool (get_mempool only, null for blocks). + final int? firstSeen; + + SaplingTransaction({ + required this.txid, + required this.outputs, + required this.spends, + this.firstSeen, + }); + + factory SaplingTransaction.fromJson(Map json) { + return SaplingTransaction( + txid: json['txid'] as String, + firstSeen: _optionalInt(json['first_seen']), + outputs: (json['outputs'] as List?) + ?.map((e) => + SaplingShieldedOutput.fromJson(e as Map)) + .toList() ?? + [], + spends: (json['spends'] as List?) + ?.map((e) => SaplingSpend.fromJson(e as Map)) + .toList() ?? + [], + ); + } +} + +class SaplingBlock { + final int height; + + final String hash; + + /// Unix epoch. + final int time; + + final List txs; + + SaplingBlock({ + required this.height, + required this.hash, + required this.time, + required this.txs, + }); + + factory SaplingBlock.fromJson(Map json) { + return SaplingBlock( + height: json['height'] as int, + hash: json['hash'] as String, + time: json['time'] as int, + txs: (json['txs'] as List?) + ?.map( + (e) => SaplingTransaction.fromJson(e as Map)) + .toList() ?? + [], + ); + } + + int get outputCount { + int count = 0; + for (final tx in txs) { + count += tx.outputs.length; + } + return count; + } + + int get spendCount { + int count = 0; + for (final tx in txs) { + count += tx.spends.length; + } + return count; + } + + List get allNullifiers { + final nullifiers = []; + for (final tx in txs) { + for (final spend in tx.spends) { + nullifiers.add(spend.nullifierBytes); + } + } + return nullifiers; + } + + List get allOutputs { + final outputs = []; + for (final tx in txs) { + outputs.addAll(tx.outputs); + } + return outputs; + } +} + +/// Snapshot from get_mempool: unconfirmed Sapling txs (same tx shape as a block, +/// no height/position). [truncated] true when the server hit its output cap. +class SaplingMempoolResult { + final List txs; + final bool truncated; + + SaplingMempoolResult({required this.txs, this.truncated = false}); + + factory SaplingMempoolResult.fromJson(Map json) { + return SaplingMempoolResult( + txs: (json['txs'] as List?) + ?.map( + (e) => SaplingTransaction.fromJson(e as Map)) + .toList() ?? + const [], + truncated: json['truncated'] == true, + ); + } +} + +/// Result from get_best_anchor RPC. +class BestAnchorResult { + /// The current best anchor (Merkle root). + final String anchor; + + final int height; + + BestAnchorResult({ + required this.anchor, + required this.height, + }); + + factory BestAnchorResult.fromJson(Map json) { + final anchor = json['anchor'] as String? ?? json['root'] as String?; + final height = _optionalInt(json['anchor_height']) ?? + _optionalInt(json['anchorHeight']) ?? + _optionalInt(json['height']); + if (anchor == null || anchor.isEmpty) { + throw SaplingRpcException( + 'PIVX Sapling best-anchor response has no anchor'); + } + if (height == null) { + throw SaplingRpcException( + 'PIVX Sapling best-anchor response has no anchor height'); + } + + return BestAnchorResult( + anchor: anchor, + height: height, + ); + } + + Uint8List get anchorBytes => Uint8List.fromList(hex.decode(anchor)); +} + +/// Parsed v1 `blockchain.sapling.get_tree_state` response. +/// +/// The v1 contract dropped `nullifier_count`; anchors/roots are display-order +/// hex and must be reversed (see [reverseSaplingHexBytes]) if ever fed to the +/// native crypto boundary. +class SaplingTreeState { + final String? anchor; + final String? root; + final String? latestAnchor; + final int? treeSize; + final int? commitmentCount; + final int? height; + final int? indexedHeight; + final int? anchorFirstHeight; + final int? saplingActivationHeight; + final String? blockHash; + + SaplingTreeState({ + this.anchor, + this.root, + this.latestAnchor, + this.treeSize, + this.commitmentCount, + this.height, + this.indexedHeight, + this.anchorFirstHeight, + this.saplingActivationHeight, + this.blockHash, + }); + + factory SaplingTreeState.fromJson(Map json) { + return SaplingTreeState( + anchor: _optionalString(json['anchor']) ?? _optionalString(json['root']), + root: _optionalString(json['root']) ?? _optionalString(json['anchor']), + latestAnchor: _optionalString(json['latest_anchor']), + treeSize: _optionalInt(json['tree_size']), + commitmentCount: _optionalInt(json['commitment_count']), + height: _optionalInt(json['height']), + indexedHeight: _optionalInt(json['indexed_height']), + anchorFirstHeight: _optionalInt(json['anchor_first_height']), + saplingActivationHeight: _optionalInt(json['sapling_activation_height']), + blockHash: _optionalString(json['block_hash']), + ); + } +} + +/// Anchor-bound Merkle witness for spend proof construction. +class SaplingWitnessResult { + static const String sourceUnknown = 'unknown'; + static const String sourceAnchorBound = 'anchor_bound'; + static const String sourceCommitmentOnlyFallback = 'commitment_only_fallback'; + static const int saplingTreeDepth = 32; + static const int saplingNodeHexLength = 64; + static final BigInt _jubjubBaseFieldModulus = BigInt.parse( + '73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001', + radix: 16); + static const List _emptyRoots = [ + '0100000000000000000000000000000000000000000000000000000000000000', + '817de36ab2d57feb077634bca77819c8e0bd298c04f6fed0e6a83cc1356ca155', + 'ffe9fc03f18b176c998806439ff0bb8ad193afdb27b2ccbc88856916dd804e34', + 'd8283386ef2ef07ebdbb4383c12a739a953a4d6e0d6fb1139a4036d693bfbb6c', + 'e110de65c907b9dea4ae0bd83a4b0a51bea175646a64c12b4c9f931b2cb31b49', + '912d82b2c2bca231f71efcf61737fbf0a08befa0416215aeef53e8bb6d23390a', + '8ac9cf9c391e3fd42891d27238a81a8a5c1d3a72b1bcbea8cf44a58ce7389613', + 'd6c639ac24b46bd19341c91b13fdcab31581ddaf7f1411336a271f3d0aa52813', + '7b99abdc3730991cc9274727d7d82d28cb794edbc7034b4f0053ff7c4b680444', + '43ff5457f13b926b61df552d4e402ee6dc1463f99a535f9a713439264d5b616b', + 'ba49b659fbd0b7334211ea6a9d9df185c757e70aa81da562fb912b84f49bce72', + '4777c8776a3b1e69b73a62fa701fa4f7a6282d9aee2c7a6b82e7937d7081c23c', + 'ec677114c27206f5debc1c1ed66f95e2b1885da5b7be3d736b1de98579473048', + '1b77dac4d24fb7258c3c528704c59430b630718bec486421837021cf75dab651', + 'bd74b25aacb92378a871bf27d225cfc26baca344a1ea35fdd94510f3d157082c', + 'd6acdedf95f608e09fa53fb43dcd0990475726c5131210c9e5caeab97f0e642f', + '1ea6675f9551eeb9dfaaa9247bc9858270d3d3a4c5afa7177a984d5ed1be2451', + '6edb16d01907b759977d7650dad7e3ec049af1a3d875380b697c862c9ec5d51c', + 'cd1c8dbf6e3acc7a80439bc4962cf25b9dce7c896f3a5bd70803fc5a0e33cf00', + '6aca8448d8263e547d5ff2950e2ed3839e998d31cbc6ac9fd57bc6002b159216', + '8d5fa43e5a10d11605ac7430ba1f5d81fb1b68d29a640405767749e841527673', + '08eeab0c13abd6069e6310197bf80f9c1ea6de78fd19cbae24d4a520e6cf3023', + '0769557bc682b1bf308646fd0b22e648e8b9e98f57e29f5af40f6edb833e2c49', + '4c6937d78f42685f84b43ad3b7b00f81285662f85c6a68ef11d62ad1a3ee0850', + 'fee0e52802cb0c46b1eb4d376c62697f4759f6c8917fa352571202fd778fd712', + '16d6252968971a83da8521d65382e61f0176646d771c91528e3276ee45383e4a', + 'd2e1642c9a462229289e5b0e3b7f9008e0301cbb93385ee0e21da2545073cb58', + 'a5122c08ff9c161d9ca6fc462073396c7d7d38e8ee48cdb3bea7e2230134ed6a', + '28e7b841dcbc47cceb69d7cb8d94245fb7cb2ba3a7a6bc18f13f945f7dbd6e2a', + 'e1f34b034d4a3cd28557e2907ebf990c918f64ecb50a94f01d6fda5ca5c7ef72', + '12935f14b676509b81eb49ef25f39269ed72309238b4c145803544b646dca62d', + 'b2eed031d4d6a4f02a097f80b54cc1541d4163c6b6f5971f88b6e41d35c53814', + 'fbc2f4300c01f0b7820d00e3347c8da4ee614674376cbc45359daa54f9b5493e', + ]; + + final int position; + final List path; + final String anchor; + final int anchorHeight; + final String commitment; + final String source; + final Map raw; + + SaplingWitnessResult({ + required this.position, + required this.path, + required this.anchor, + required this.anchorHeight, + required this.commitment, + this.source = sourceUnknown, + required this.raw, + }); + + factory SaplingWitnessResult.fromJson(Map json) { + final rawPath = _normalizeWitnessPath(json['path'] ?? json['witness']); + final path = _expandWitnessPath(rawPath); + final anchor = json['anchor'] as String? ?? json['root'] as String?; + final anchorHeight = _optionalInt(json['anchor_height']) ?? + _optionalInt(json['height']) ?? + _optionalInt(json['anchorHeight']); + final commitment = json['commitment'] as String? ?? + json['cmu'] as String? ?? + json['commitment_hex'] as String?; + final position = _optionalInt(json['position']) ?? + _optionalInt(json['tree_position']) ?? + _optionalInt(json['global_position']); + + if (rawPath == null || rawPath.isEmpty) { + throw SaplingRpcException('PIVX Sapling witness response has no path'); + } + if (path == null) { + throw SaplingRpcException( + 'PIVX Sapling witness response has invalid path'); + } + if (anchor == null || anchor.isEmpty) { + throw SaplingRpcException('PIVX Sapling witness response has no anchor'); + } + if (anchorHeight == null) { + throw SaplingRpcException( + 'PIVX Sapling witness response has no anchor height'); + } + if (commitment == null || commitment.isEmpty) { + throw SaplingRpcException( + 'PIVX Sapling witness response has no commitment'); + } + if (position == null) { + throw SaplingRpcException( + 'PIVX Sapling witness response has no note position'); + } + + return SaplingWitnessResult( + position: position, + path: path, + anchor: anchor, + anchorHeight: anchorHeight, + commitment: commitment, + source: _optionalString(json['source']) ?? sourceUnknown, + raw: Map.from(json), + ); + } + + SaplingWitnessResult withSource(String source) => SaplingWitnessResult( + position: position, + path: path, + anchor: anchor, + anchorHeight: anchorHeight, + commitment: commitment, + source: source, + raw: { + ...raw, + 'source': source, + }, + ); + + static List? _normalizeWitnessPath(Object? rawPath) { + if (rawPath == null) return null; + if (rawPath is String) { + final normalized = _normalizeWitnessPathElement(rawPath); + return normalized == null ? null : [normalized]; + } + if (rawPath is! List) return null; + + final path = []; + for (final element in rawPath) { + final normalized = _normalizeWitnessPathElement(element); + if (normalized == null) return null; + path.add(normalized); + } + return path; + } + + static String? _normalizeWitnessPathElement(Object? element) { + if (element == null) return null; + if (element is String) { + return element; + } + if (element is Map) { + for (final key in const [ + 'hash', + 'hex', + 'node', + 'sibling', + 'value', + 'cmu', + ]) { + final value = element[key]; + if (value is String && value.isNotEmpty) { + return value; + } + } + return null; + } + if (element is List) { + for (final value in element) { + if (value is String && value.isNotEmpty) { + return value; + } + } + } + return null; + } + + static List? _expandWitnessPath(List? rawPath) { + if (rawPath == null || rawPath.isEmpty) return rawPath; + final path = _splitWitnessPath(rawPath); + if (path == null || path.length > saplingTreeDepth) return null; + + final expanded = _padWitnessPath(path); + final invalidIndex = _firstNonCanonicalNodeIndex(expanded); + if (invalidIndex == null) return expanded; + + final reversedPath = path.map(_reverseNodeHex).toList(growable: false); + final reversedExpanded = _padWitnessPath(reversedPath); + final reversedInvalidIndex = _firstNonCanonicalNodeIndex(reversedExpanded); + if (reversedInvalidIndex == null) { + printV('[PIVX Sapling] Witness path byte order corrected'); + return reversedExpanded; + } + + final originalCanonical = path + .where( + (node) => _littleEndianHexToBigInt(node) < _jubjubBaseFieldModulus) + .length; + final reversedCanonical = reversedPath + .where( + (node) => _littleEndianHexToBigInt(node) < _jubjubBaseFieldModulus) + .length; + printV( + '[PIVX Sapling] Witness path has non-canonical node at index $invalidIndex; canonical_original=$originalCanonical/${path.length}, canonical_reversed=$reversedCanonical/${reversedPath.length}'); + return null; + } + + static List? _splitWitnessPath(List rawPath) { + final path = []; + for (final element in rawPath) { + final hexElement = element.trim(); + if (hexElement.isEmpty || + hexElement.length % saplingNodeHexLength != 0 || + !RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexElement)) { + return null; + } + for (var offset = 0; + offset < hexElement.length; + offset += saplingNodeHexLength) { + path.add(hexElement + .substring(offset, offset + saplingNodeHexLength) + .toLowerCase()); + } + } + return path; + } + + static List _padWitnessPath(List path) { + final expanded = List.from(path); + if (path.length < saplingTreeDepth) { + expanded.addAll(_emptyRoots.skip(path.length).take( + saplingTreeDepth - path.length, + )); + } + return expanded; + } + + static int? _firstNonCanonicalNodeIndex(List path) { + for (var i = 0; i < path.length; i++) { + if (_littleEndianHexToBigInt(path[i]) >= _jubjubBaseFieldModulus) { + return i; + } + } + return null; + } + + static BigInt _littleEndianHexToBigInt(String hexValue) { + final buffer = StringBuffer(); + for (var offset = hexValue.length; offset > 0; offset -= 2) { + buffer.write(hexValue.substring(offset - 2, offset)); + } + return BigInt.parse(buffer.toString(), radix: 16); + } + + static String _reverseNodeHex(String hexValue) { + final buffer = StringBuffer(); + for (var offset = hexValue.length; offset > 0; offset -= 2) { + buffer.write(hexValue.substring(offset - 2, offset)); + } + return buffer.toString(); + } +} + +/// Parsed v1 `blockchain.sapling.get_active_heights` response. +class SaplingActiveHeightsResult { + const SaplingActiveHeightsResult({ + required this.heights, + required this.start, + required this.end, + required this.complete, + this.dbHeight, + }); + + /// Ascending, unique block heights with >=1 Sapling tx in this page. + final List heights; + + /// First / last height covered by this page. On truncation resume at [end]+1. + final int start; + final int end; + + /// False when the page was truncated by the server's limit. + final bool complete; + + /// Indexed ceiling (`db_height`) at the time of the call, when reported. + final int? dbHeight; + + factory SaplingActiveHeightsResult.fromJson( + Map json, + int requestStart, + int requestEnd, + ) { + final rawHeights = json['heights']; + final heights = []; + if (rawHeights is List) { + for (final h in rawHeights) { + final v = _optionalInt(h); + if (v != null) heights.add(v); + } + } + heights.sort(); + return SaplingActiveHeightsResult( + heights: heights, + start: _optionalInt(json['start']) ?? requestStart, + end: _optionalInt(json['end']) ?? requestEnd, + complete: json['complete'] == true, + dbHeight: _optionalInt(json['db_height']), + ); + } +} + +/// Wraps an ElectrumX client to add Sapling-specific RPC methods. +class PIVXSaplingElectrumX { + /// Underlying ElectrumX client (ElectrumWallet.electrumClient). + final dynamic _client; + + final bool isTestnet; + + /// Verifies witness Merkle roots locally before a witness is accepted. + /// Defaults to the native sapling_ffi implementation; tests inject a fake. + final WitnessRootVerifier _witnessRootVerifier; + + PIVXSaplingElectrumX({ + required dynamic electrumClient, + this.isTestnet = false, + WitnessRootVerifier? witnessRootVerifier, + SaplingRpcCapabilities? capabilities, + }) : _client = electrumClient, + _capabilities = capabilities, + _witnessRootVerifier = + witnessRootVerifier ?? sapling_ffi.verifyWitnessRoot; + + SaplingRpcCapabilities? _capabilities; + + /// The capabilities negotiated for the active node, if already probed. + SaplingRpcCapabilities? get capabilities => _capabilities; + + int get activationHeight => + isTestnet ? SaplingActivation.testnet : SaplingActivation.mainnet; + + Future _callFirstSupported({ + required List methods, + required List params, + bool fallbackOnServerError = false, + }) async { + Object? lastError; + for (final method in methods) { + try { + int? requestId; + final result = await _client.call( + method: method, + params: params, + idCallback: (id) => requestId = id, + ); + final errorMessage = _errorMessageForRequest(requestId); + if (errorMessage != null) { + throw SaplingRpcException(errorMessage); + } + return result; + } catch (e) { + lastError = e; + if (!_looksLikeUnsupportedMethod(e) && + !(fallbackOnServerError && _looksLikeServerMethodFailure(e))) { + rethrow; + } + } + } + throw SaplingRpcException('PIVX Sapling RPC method unavailable', lastError); + } + + String? _errorMessageForRequest(int? requestId) { + if (requestId == null) return null; + try { + final message = _client.getErrorMessage(requestId); + if (message is String && message.isNotEmpty) { + return message; + } + } catch (_) {} + return null; + } + + bool _looksLikeUnsupportedMethod(Object error) { + final text = error.toString().toLowerCase(); + return text.contains('method not found') || + text.contains('unknown method') || + text.contains('unsupported') || + text.contains('not implemented') || + text.contains('method unavailable'); + } + + bool _looksLikeServerMethodFailure(Object error) { + final text = error.toString().toLowerCase(); + return text.contains('internal server error') || + text.contains('server error'); + } + + /// probe attempts before surfacing a not-supported result. a healthy node can + /// briefly return an incomplete caps payload after a reconnect (methods list + /// missing get_block_range); retrying keeps that blip from reading as an + /// unsupported node and firing a "switch nodes" error on the sync poll. + static const int _capabilityProbeAttempts = 3; + + /// Probe the Sapling RPC policy/capabilities for the active node. Caches the + /// first good result; retries a transient/incomplete payload before giving up. + Future probeCapabilities() async { + if (_capabilities != null) return _capabilities!; + + Object? lastCause; + for (var attempt = 0; attempt < _capabilityProbeAttempts; attempt++) { + try { + return await _probeCapabilitiesOnce(); + } on _RetryableCapabilityProbe catch (e) { + // only an incomplete/garbled payload retries; definitive rejections + // (wrong network, half-upgraded v1, unsupported node) aren't + // _RetryableCapabilityProbe and propagate immediately. + lastCause = e.cause; + if (attempt < _capabilityProbeAttempts - 1) { + await Future.delayed( + Duration(milliseconds: 300 * (attempt + 1))); + } + } + } + throw lastCause!; + } + + /// live index ceiling (`index_status.db_height`), fetched fresh each call, not + /// the value cached in [capabilities] at probe time. the cached ceiling never + /// moves, so it stalls the sync at the first-observed height (new receives + /// never scanned). null when unavailable; caller falls back to cached ceiling + /// or header tip. + Future fetchLiveIndexHeight() async { + try { + final result = await _callFirstSupported( + methods: const [ + 'blockchain.sapling.capabilities', + 'blockchain.sapling.get_capabilities', + ], + params: [], + fallbackOnServerError: true, + ); + if (result is Map) { + final idx = result['index_status']; + if (idx is Map) return _optionalInt(idx['db_height']); + } + } catch (_) { + // fall through to null; caller uses the cached ceiling / header tip + } + return null; + } + + /// Current unconfirmed Sapling mempool snapshot for 0-conf incoming detection. + /// null on not-ready/error (transient, caller keeps prior state); a non-null + /// result with empty txs is an authoritative empty mempool (caller clears). + /// best-effort: mempool is display-only, never fatal to the block sync. + Future fetchMempool() async { + try { + final result = await _callFirstSupported( + methods: const [ + 'blockchain.sapling.get_mempool', + 'sapling.get_mempool', + ], + params: [], + fallbackOnServerError: true, + ); + if (result is! Map) return null; + // mempool_not_ready (server trails its daemon / no snapshot) is retryable; + // a healthy empty mempool returns success with txs: []. only the latter + // clears prior state, so any error/failure maps to null. + if (result['success'] == false || result['error'] != null) return null; + return SaplingMempoolResult.fromJson(Map.from(result)); + } catch (_) { + return null; + } + } + + /// Subscribe to the Sapling mempool push feed. Emits the current snapshot + /// first, then the same envelope on every change (full state replacement). + /// null when unsupported or not connected; caller falls back to polling. + Stream? mempoolSubscribe() { + try { + final subject = _client.saplingMempoolSubscribe(); + if (subject is! Stream) return null; + return subject + .map(_parseMempoolPush) + .where((result) => result != null) + .cast(); + } catch (_) { + return null; + } + } + + Future mempoolUnsubscribe() async { + try { + await _client.call( + method: 'blockchain.sapling.mempool.unsubscribe', + params: [], + ); + } catch (_) {} + } + + /// Parse a mempool subscribe payload (initial snapshot or push) into a result. + /// May arrive as the envelope map or wrapped in a params list; a not-ready or + /// error payload yields null so the caller keeps its prior state. + SaplingMempoolResult? _parseMempoolPush(dynamic event) { + dynamic payload = event; + if (payload is List && payload.isNotEmpty) payload = payload.first; + if (payload is! Map) return null; + if (payload['success'] == false || payload['error'] != null) return null; + return SaplingMempoolResult.fromJson(Map.from(payload)); + } + + Future _probeCapabilitiesOnce() async { + try { + final result = await _callFirstSupported( + methods: const [ + 'blockchain.sapling.capabilities', + 'blockchain.sapling.get_capabilities', + ], + params: [], + fallbackOnServerError: true, + ); + if (result is! Map) { + throw _RetryableCapabilityProbe(SaplingRpcException( + 'PIVX Sapling capability probe returned ${result.runtimeType}')); + } + final capabilities = + SaplingRpcCapabilities.fromJson(Map.from(result)); + if (!capabilities.supportsBlockRange) { + throw _RetryableCapabilityProbe(SaplingRpcException( + 'PIVX Sapling node does not advertise get_block_range')); + } + if (capabilities.advertisesV1Contract && + !capabilities.supportsV1ReleaseContract) { + throw SaplingRpcException( + 'PIVX Sapling node advertises v1 but is missing required release contract features'); + } + if (capabilities.network != null) { + final expected = isTestnet ? 'testnet' : 'mainnet'; + if (capabilities.network!.toLowerCase() != expected) { + throw SaplingRpcException( + 'PIVX Sapling node network mismatch: expected $expected'); + } + } + if (capabilities.activationHeight != null && + capabilities.activationHeight != activationHeight) { + throw SaplingRpcException( + 'PIVX Sapling activation height mismatch for current network'); + } + if (capabilities.supportsV1ReleaseContract) { + await _validateLiveV1ReleaseMethods(); + } + _capabilities = capabilities; + return capabilities; + } catch (e) { + if (e is _RetryableCapabilityProbe) rethrow; // let the wrapper retry it + if (!_looksLikeUnsupportedMethod(e)) rethrow; + + // Legacy sapling_integration fork: prove block-range support, but do not + // assume global positions, witnesses, or v1 policy metadata exist. + await getBlockRange(activationHeight, endHeight: activationHeight); + _capabilities = SaplingRpcCapabilities.legacyBlockRangeOnly(); + return _capabilities!; + } + } + + Future _validateLiveV1ReleaseMethods() async { + try { + final anchorResult = await _callFirstSupported( + methods: const ['blockchain.sapling.get_best_anchor'], + params: [], + ); + if (anchorResult is! Map) { + throw SaplingRpcException( + 'get_best_anchor returned ${anchorResult.runtimeType}'); + } + BestAnchorResult.fromJson(Map.from(anchorResult)); + + final nullifierResult = await _callFirstSupported( + methods: const ['blockchain.sapling.get_nullifier_status'], + params: const [_v1LiveProbeHex32], + ); + if (nullifierResult is! Map) { + throw SaplingRpcException( + 'get_nullifier_status returned ${nullifierResult.runtimeType}'); + } + NullifierStatus.fromJson(Map.from(nullifierResult)); + + final commitmentResult = await _callFirstSupported( + methods: const ['blockchain.sapling.get_commitment_info'], + params: const [_v1LiveProbeHex32], + ); + if (commitmentResult is! Map) { + throw SaplingRpcException( + 'get_commitment_info returned ${commitmentResult.runtimeType}'); + } + CommitmentInfo.fromJson(Map.from(commitmentResult)); + } catch (e) { + throw SaplingRpcException( + 'PIVX Sapling node advertises v1 but live release method validation failed', + e, + ); + } + } + + /// Check if [nullifier] (32-byte hex) has been spent. + Future getNullifierStatus(String nullifier) async { + final result = await _callFirstSupported( + methods: const [ + 'blockchain.sapling.get_nullifier_status', + 'blockchain.nullifier.get_spend', + ], + params: [nullifier], + ); + return NullifierStatus.fromJson(result as Map); + } + + /// Info about a note commitment [commitment] (32-byte cmu hex). + Future getCommitmentInfo(String commitment) async { + final result = await _callFirstSupported( + methods: const [ + 'blockchain.sapling.get_commitment_info', + 'blockchain.commitment.get_info', + ], + params: [commitment], + ); + return CommitmentInfo.fromJson(result as Map); + } + + /// Sapling outputs in a block range (inclusive; [endHeight] defaults to + /// [startHeight]). [limit] default 1000, max 5000; max 100 blocks per request. + Future getOutputsByHeight( + int startHeight, { + int? endHeight, + int? limit, + }) async { + final params = [startHeight]; + if (endHeight != null) params.add(endHeight); + if (limit != null) params.add(limit); + + final result = await _callFirstSupported( + methods: const [ + 'blockchain.sapling.get_outputs_by_height', + 'blockchain.sapling.get_outputs', + ], + params: params, + ); + return SaplingOutputsResult.fromJson(result as Map); + } + + /// sparse block heights in [startHeight, endHeight] with >=1 Sapling tx (v1 + /// active-height index). one response may be truncated (complete == false); + /// use [fetchActiveHeights] to page the whole range. + Future getActiveHeights( + int startHeight, { + int? endHeight, + int? limit, + }) async { + final params = [startHeight]; + if (endHeight != null) params.add(endHeight); + if (limit != null) params.add(limit); + + final result = await _callFirstSupported( + methods: const [ + 'blockchain.sapling.get_active_heights', + 'sapling.get_active_heights', + ], + params: params, + ); + return SaplingActiveHeightsResult.fromJson( + result as Map, + startHeight, + endHeight ?? startHeight, + ); + } + + /// page [getActiveHeights] across [fromHeight]..[toHeight] until complete. + /// returns the full ascending set, or null when the node can't serve the index + /// (capability off / unknown method / not ready) so the caller full-scans. + Future?> fetchActiveHeights(int fromHeight, int toHeight) async { + if (!(capabilities?.supportsActiveHeights ?? false)) return null; + if (toHeight < fromHeight) return const []; + + final limit = capabilities?.activeHeightsMaxLimit ?? 10000; + final heights = []; + var cursor = fromHeight; + var covered = false; + // Bound the paging so a misbehaving node can't loop forever. + for (var page = 0; page < 512; page++) { + if (cursor > toHeight) { + covered = true; // paged the entire requested range + break; + } + final SaplingActiveHeightsResult result; + try { + result = + await getActiveHeights(cursor, endHeight: toHeight, limit: limit); + } catch (_) { + return null; // unknown method / not ready -> full-scan fallback + } + heights.addAll( + result.heights.where((h) => h >= cursor && h <= toHeight)); + if (result.complete) { + covered = true; + break; + } + final nextCursor = result.end + 1; + if (nextCursor <= cursor) break; // no forward progress -> incomplete + cursor = nextCursor; + } + // only trust the set if we fully covered the range. a truncated/no-progress/ + // page-capped result must full-scan, or _syncActiveWindows advances the + // cursor past active blocks never returned and drops their notes. + if (!covered) return null; + return heights; + } + + /// Blocks with Sapling txs in pivx-shield format (inclusive; [endHeight] + /// defaults to [startHeight]). Max 100 blocks per request; only blocks with + /// Sapling txs are returned. + Future> getBlockRange( + int startHeight, { + int? endHeight, + }) async => + (await getBlockRangeResult(startHeight, endHeight: endHeight)).blocks; + + /// Get blocks plus v1 envelope metadata for a Sapling height range. + Future getBlockRangeResult( + int startHeight, { + int? endHeight, + }) async { + final expectedEnd = endHeight ?? startHeight; + final params = [startHeight]; + if (endHeight != null) params.add(endHeight); + + final result = await _callFirstSupported( + methods: const ['blockchain.sapling.get_block_range'], + params: params, + ); + + if (result == null) { + throw SaplingRpcException( + 'PIVX Sapling get_block_range returned null for $startHeight-$expectedEnd', + ); + } + + dynamic blocksResult = result; + var blockHashes = {}; + if (result is Map) { + // v1 envelope: a range above the indexed tip returns + // success:false / complete:false with a structured error. Classify + // indexer-lag / backend-timeout errors as retryable so the sync loop + // retries instead of advancing the synced height past them. + final errorType = _rangeErrorType(result['error']); + if (result['success'] == false || errorType != null) { + final detail = errorType ?? 'unknown'; + if (_retryableRangeErrorTypes.contains(errorType)) { + throw SaplingRetryableRangeException( + 'PIVX Sapling get_block_range not ready for $startHeight-$expectedEnd (error=$detail)', + ); + } + throw SaplingRpcException( + 'PIVX Sapling get_block_range failed for $startHeight-$expectedEnd (error=$detail)', + ); + } + if (result['complete'] != true) { + throw SaplingRpcException( + 'PIVX Sapling get_block_range returned an incomplete range for $startHeight-$expectedEnd', + ); + } + final responseStart = _optionalInt(result['from_height']) ?? + _optionalInt(result['start_height']) ?? + _optionalInt(result['from']); + final responseEnd = _optionalInt(result['to_height']) ?? + _optionalInt(result['end_height']) ?? + _optionalInt(result['to']); + if (responseStart != null && responseStart != startHeight) { + throw SaplingRpcException( + 'PIVX Sapling get_block_range returned a mismatched start height', + ); + } + if (responseEnd != null && responseEnd != expectedEnd) { + throw SaplingRpcException( + 'PIVX Sapling get_block_range returned a mismatched end height', + ); + } + blockHashes = _parseBlockHashes( + result['block_hashes'] ?? result['blockHashes'], + responseStart ?? startHeight, + ); + blocksResult = result['blocks']; + } + + if (blocksResult is! List) { + throw SaplingRpcException( + 'PIVX Sapling get_block_range returned ${blocksResult.runtimeType} for $startHeight-$expectedEnd', + ); + } + + final List blocks; + try { + blocks = blocksResult + .map((e) => SaplingBlock.fromJson(e as Map)) + .toList(); + } catch (e) { + throw SaplingRpcException( + 'PIVX Sapling get_block_range returned malformed block data', + e, + ); + } + for (final block in blocks) { + if (block.hash.isNotEmpty) { + blockHashes[block.height] = block.hash; + } + } + + return SaplingBlockRangeResult( + startHeight: startHeight, + endHeight: expectedEnd, + blocks: blocks, + blockHashes: blockHashes, + ); + } + + Map _parseBlockHashes(Object? raw, int startHeight) { + final hashes = {}; + if (raw is Map) { + for (final entry in raw.entries) { + final height = entry.key is int + ? entry.key as int + : int.tryParse(entry.key.toString()); + final hash = entry.value?.toString(); + if (height != null && hash != null && hash.isNotEmpty) { + hashes[height] = hash; + } + } + } else if (raw is List) { + for (var i = 0; i < raw.length; i++) { + final item = raw[i]; + if (item is Map) { + final height = _optionalInt(item['height']) ?? + _optionalInt(item['block_height']); + final hash = _optionalString(item['hash']) ?? + _optionalString(item['block_hash']); + if (height != null && hash != null) { + hashes[height] = hash; + } + } else if (item != null) { + final hash = item.toString(); + if (hash.isNotEmpty) { + hashes[startHeight + i] = hash; + } + } + } + } + return hashes; + } + + /// Block height where [anchor] (32-byte Merkle root hex) was valid; null if + /// not found. + Future getAnchorHeight(String anchor) async { + final result = await _callFirstSupported( + methods: const [ + 'blockchain.sapling.get_anchor_height', + 'blockchain.anchor.get_height', + ], + params: [anchor], + ); + return result as int?; + } + + /// Best (most recent) anchor and its height. Searches for the most recent + /// height with a tree state (only blocks with Sapling activity have one); + /// [maxHeight] defaults to the chain tip. + Future getBestAnchor({int? maxHeight}) async { + try { + final result = await _client.call( + method: 'blockchain.sapling.get_best_anchor', + params: maxHeight == null ? [] : [maxHeight], + ); + if (result is Map) { + return BestAnchorResult.fromJson(result); + } + } catch (e) { + if (!_looksLikeUnsupportedMethod(e)) rethrow; + } + + int searchHeight = maxHeight ?? 0; + if (searchHeight == 0) { + final headersResult = await _client.call( + method: 'blockchain.headers.subscribe', + params: [], + ); + searchHeight = headersResult['height'] as int; + } + + var treeState = await getTreeState(searchHeight); + + // the server may only have tree states for blocks with Sapling txs + if (treeState == null) { + int step = 1000; + int minHeight = activationHeight; + + while (treeState == null && searchHeight > minHeight) { + searchHeight -= step; + if (searchHeight < minHeight) searchHeight = minHeight; + treeState = await getTreeState(searchHeight); + + if (treeState == null && step > 10) { + searchHeight += step; + step = step ~/ 10; + } + } + } + + if (treeState == null) { + throw Exception( + 'Could not find any tree state from height $maxHeight down to $activationHeight'); + } + + final parsedTreeState = SaplingTreeState.fromJson(treeState); + final anchor = parsedTreeState.root ?? parsedTreeState.anchor; + if (anchor == null) { + throw Exception('Tree state at height $searchHeight has no root/anchor'); + } + + return BestAnchorResult( + anchor: anchor, + height: searchHeight, + ); + } + + /// Sapling commitment tree state at [height]. + Future?> getTreeState(int height) async { + final result = await _callFirstSupported( + methods: const ['blockchain.sapling.get_tree_state'], + params: [height]); + return result as Map?; + } + + /// Get Merkle witness for spend proof construction. + /// + /// The v1 release contract uses commitment + anchor root. Some simulator and + /// development nodes have exposed compatible witness data behind position or + /// height-bound parameter shapes, so callers that need compatibility should + /// use [getAnchorBoundWitness] instead of calling this low-level method. + Future?> getWitness( + Object commitmentOrPosition, Object? anchorOrHeight) async { + final params = [commitmentOrPosition]; + if (anchorOrHeight != null) { + params.add(anchorOrHeight); + } + final result = await _callFirstSupported( + methods: const ['blockchain.sapling.get_witness'], params: params); + return result as Map?; + } + + /// Get a witness that is explicitly bound to the selected anchor. + /// + /// Shielded spend construction must sign with the same anchor used to build + /// every witness path. Nodes that omit anchor metadata, return a witness for + /// a different anchor height/root, or return a different commitment are + /// rejected before proving starts. + Future getAnchorBoundWitness({ + required String commitment, + required BestAnchorResult anchor, + int? notePosition, + }) async { + final attempts = >[ + { + 'label': 'commitment_anchor', + 'params': [commitment, anchor.anchor], + 'retries': 1, + }, + { + 'label': 'commitment_only', + 'params': [commitment, null], + 'retries': 2, + }, + ]; + + final failures = []; + for (final attempt in attempts) { + final params = attempt['params'] as List; + final label = attempt['label'] as String; + final retries = attempt['retries'] as int; + for (var retry = 1; retry <= retries; retry++) { + try { + final witnessData = await getWitness(params[0]!, params[1]); + if (witnessData == null) { + throw SaplingRpcException('PIVX Sapling witness response is null'); + } + + final witness = SaplingWitnessResult.fromJson( + Map.from(witnessData)); + if (label == 'commitment_only') { + _validateWitnessCommitment( + witness: witness, + commitment: commitment, + ); + } else { + _validateAnchorBoundWitness( + witness: witness, + commitment: commitment, + anchor: anchor, + ); + } + // SECURITY: the anchor this witness will be spent against must be + // recomputable locally from (cmu, position, path). For the + // commitment-only fallback the server-selected witness anchor + // becomes the spend anchor, so verify against that root. + _verifyWitnessRoot( + witness: witness, + commitment: commitment, + expectedAnchor: + label == 'commitment_only' ? witness.anchor : anchor.anchor, + ); + final source = label == 'commitment_only' + ? SaplingWitnessResult.sourceCommitmentOnlyFallback + : SaplingWitnessResult.sourceAnchorBound; + printV('[PIVX Sapling] Witness accepted via $source'); + return witness.withSource(source); + } catch (e) { + final reason = _witnessFailureReason(e); + failures.add('$label:$reason'); + printV( + '[PIVX Sapling] Witness attempt $label $retry/$retries failed: $reason'); + } + } + } + + throw SaplingRpcException( + 'PIVX Sapling witness lookup failed for selected note position; attempts=${failures.join(',')}', + ); + } + + /// Reject the witness unless its Merkle root, recomputed locally from the + /// note commitment, position, and sibling path, equals [expectedAnchor]. + void _verifyWitnessRoot({ + required SaplingWitnessResult witness, + required String commitment, + required String expectedAnchor, + }) { + // The native verifier (and the prover) work in serialization order. When + // the node speaks display order, reverse cmu and anchor to serialization + // before verifying. The witness PATH is already serialization order + // (raw sapling_node_to_bytes_hex) and must NOT be reversed. Proven against + // real chainster data in rust/src/notes.rs + // (chainster_v1_witness_needs_display_to_serialization_reversal). + final display = _capabilities?.usesDisplayByteOrder == true; + final cmuHex = display ? reverseSaplingHexBytes(commitment) : commitment; + final anchorHex = + display ? reverseSaplingHexBytes(expectedAnchor) : expectedAnchor; + final bool valid; + try { + valid = _witnessRootVerifier( + witnessHex: witness.path.join(), + cmuHex: cmuHex, + anchorHex: anchorHex, + position: witness.position, + ); + } catch (e) { + // Verification unavailable or inputs unparseable: fail closed. + throw SaplingRpcException( + 'PIVX Sapling witness root verification failed (witness_root_mismatch)', + e); + } + if (!valid) { + throw SaplingRpcException( + 'PIVX Sapling witness root does not match the spend anchor (witness_root_mismatch)'); + } + } + + static String _witnessFailureReason(Object error) { + final text = error.toString().toLowerCase(); + + if (text.contains('witness_root_mismatch')) { + return 'witness_root_mismatch'; + } + if (text.contains('canonical_witness_unavailable') || + text.contains('witness not found') || + text.contains('commitment not found')) { + return 'canonical_witness_unavailable'; + } + if (text.contains('response is null')) { + return 'null_response'; + } + if (text.contains('no path')) { + return 'missing_path'; + } + if (text.contains('invalid path') || text.contains('non-canonical node')) { + return 'invalid_path'; + } + if (text.contains('no anchor')) { + return 'missing_anchor'; + } + if (text.contains('anchor does not match')) { + return 'anchor_mismatch'; + } + if (text.contains('height does not match')) { + return 'anchor_height_mismatch'; + } + if (text.contains('no commitment')) { + return 'missing_commitment'; + } + if (text.contains('commitment does not match')) { + return 'commitment_mismatch'; + } + if (text.contains('no note position')) { + return 'missing_position'; + } + if (text.contains('rpc method unavailable') || + text.contains('unknown method') || + text.contains('method not found')) { + return 'witness_method_unavailable'; + } + if (text.contains('internal server error') || + text.contains('server error')) { + return 'server_error'; + } + + return 'witness_lookup_failed'; + } + + void _validateAnchorBoundWitness({ + required SaplingWitnessResult witness, + required String commitment, + required BestAnchorResult anchor, + }) { + if (witness.anchor.toLowerCase() != anchor.anchor.toLowerCase()) { + throw SaplingRpcException( + 'PIVX Sapling witness anchor does not match selected anchor'); + } + if (witness.anchorHeight != anchor.height) { + throw SaplingRpcException( + 'PIVX Sapling witness height does not match selected anchor height'); + } + _validateWitnessCommitment(witness: witness, commitment: commitment); + } + + void _validateWitnessCommitment({ + required SaplingWitnessResult witness, + required String commitment, + }) { + if (witness.commitment.toLowerCase() != commitment.toLowerCase()) { + throw SaplingRpcException( + 'PIVX Sapling witness commitment does not match requested note'); + } + } + + /// Sapling data for transaction [txid] (hex). + Future?> getTransactionSapling(String txid) async { + final result = await _callFirstSupported( + methods: const ['blockchain.transaction.get_sapling'], + params: [txid]); + return result as Map?; + } + + /// Sync blocks in batches. [onRangeComplete] fires per range, even if empty. + Future syncBlocks({ + required int fromHeight, + required int toHeight, + int batchSize = 100, + int parallelBatches = 5, + required Future Function(List blocks) onBatch, + Future Function( + int rangeStart, + int rangeEnd, + Map blockHashes, + )? onRangeComplete, + bool Function()? shouldCancel, + }) async { + // Server enforces max 100 blocks per request + final effectiveBatchSize = batchSize.clamp(1, 100); + + // fast path: with the active-height index, skip every empty window (most of + // a restore) and fetch only windows with Sapling activity. falls back to the + // full scan below when unavailable. + final activeHeights = await fetchActiveHeights(fromHeight, toHeight); + if (activeHeights != null) { + await _syncActiveWindows( + fromHeight: fromHeight, + toHeight: toHeight, + batchSize: effectiveBatchSize, + parallelBatches: parallelBatches, + activeHeights: activeHeights, + onBatch: onBatch, + onRangeComplete: onRangeComplete, + shouldCancel: shouldCancel, + ); + return; + } + + int currentStart = fromHeight; + + while (currentStart <= toHeight) { + if (shouldCancel?.call() ?? false) break; + final batchFutures = >[]; + + for (int i = 0; + i < parallelBatches && + currentStart + i * effectiveBatchSize <= toHeight; + i++) { + final start = currentStart + i * effectiveBatchSize; + final end = + (start + effectiveBatchSize - 1).clamp(fromHeight, toHeight); + + batchFutures.add(_fetchBatchWithRetry(start, end)); + } + + final results = await Future.wait(batchFutures); + + // Batches are ordered low->high. A null marks the first batch at/above the + // node's indexed ceiling (or a transient backend stall): process the + // contiguous prefix we did get, then end this pass. The next pass (driven + // by a new-block notification or the periodic poll) resumes once the + // Sapling index advances, no hot-loop, no hard failure. + var reachedCeiling = false; + for (final result in results) { + if (result == null) { + reachedCeiling = true; + break; + } + if (result.blocks.isNotEmpty) { + await onBatch(result.blocks); + } + await onRangeComplete?.call( + result.startHeight, + result.endHeight, + result.blockHashes, + ); + } + if (reachedCeiling) break; + + currentStart += parallelBatches * effectiveBatchSize; + } + } + + /// aligned, non-overlapping windows (relative to [fromHeight]) holding >=1 + /// active height. each height maps to one window, so blocks never overlap + /// (overlap would double-apply the note commitment tree). ascending; each + /// window is `[start, end]` inclusive. + static List> computeActiveWindows( + int fromHeight, + int toHeight, + int batchSize, + List activeHeights, + ) { + if (batchSize < 1 || toHeight < fromHeight) return const []; + final windowStarts = {}; + for (final h in activeHeights) { + if (h < fromHeight || h > toHeight) continue; + final k = (h - fromHeight) ~/ batchSize; + windowStarts.add(fromHeight + k * batchSize); + } + final sorted = windowStarts.toList()..sort(); + return [ + for (final start in sorted) + [start, (start + batchSize - 1).clamp(fromHeight, toHeight)], + ]; + } + + /// scan only windows with Sapling activity, in ascending parallel waves, then + /// advance the persisted cursor across the trailing empty gap to [toHeight]. + /// empty blocks add zero commitments, so skipping them leaves the tree + /// position correct. + Future _syncActiveWindows({ + required int fromHeight, + required int toHeight, + required int batchSize, + required int parallelBatches, + required List activeHeights, + required Future Function(List blocks) onBatch, + Future Function(int, int, Map)? onRangeComplete, + bool Function()? shouldCancel, + }) async { + final windows = + computeActiveWindows(fromHeight, toHeight, batchSize, activeHeights); + final waveSize = parallelBatches < 1 ? 1 : parallelBatches; + + var idx = 0; + var stopped = false; + while (idx < windows.length) { + if (shouldCancel?.call() ?? false) { + stopped = true; + break; + } + final waveEnd = + (idx + waveSize) > windows.length ? windows.length : idx + waveSize; + final wave = windows.sublist(idx, waveEnd); + final results = await Future.wait( + wave.map((w) => _fetchBatchWithRetry(w[0], w[1])), + ); + + // Same low->high ordering + ceiling handling as the full scan. + var reachedCeiling = false; + for (final result in results) { + if (result == null) { + reachedCeiling = true; + break; + } + if (result.blocks.isNotEmpty) { + await onBatch(result.blocks); + } + await onRangeComplete?.call( + result.startHeight, + result.endHeight, + result.blockHashes, + ); + } + if (reachedCeiling) { + stopped = true; + break; + } + idx = waveEnd; + } + + // confirmed-empty gap after the last active window: advance the persisted + // sync height to toHeight so a resume doesn't re-scan it. skipped on early + // stop (cancel/ceiling) so progress isn't over-reported. + if (!stopped) { + await onRangeComplete?.call(fromHeight, toHeight, const {}); + } + } + + /// Returns null when the range is at/above the node's indexed ceiling or the + /// backend is transiently not ready, so the caller ends the pass and resumes + /// later. Only genuinely malformed/hard failures throw. + Future<_BatchResult?> _fetchBatchWithRetry(int start, int end, + {int retries = 2}) async { + for (int attempt = 0; attempt <= retries; attempt++) { + try { + final result = await getBlockRangeResult(start, endHeight: end) + .timeout(kSaplingBlockRangeFetchTimeout); + return _BatchResult(result.blocks, start, end, result.blockHashes); + } on SaplingRetryableRangeException { + return null; + } on TimeoutException { + // socket alive (ping answers) but node stalled on the range. treat like + // a not-ready range: end the pass, resume next poll. + return null; + } catch (e) { + if (attempt == retries) { + throw SaplingRpcException( + 'PIVX Sapling block range $start-$end failed after ${retries + 1} attempts', + e, + ); + } + await Future.delayed(Duration(milliseconds: 100 * (attempt + 1))); + } + } + return null; + } + + /// Spent status for multiple nullifiers (nullifier -> spent). + Future> checkNullifiers(List nullifiers) async { + final results = {}; + + final futures = nullifiers.map((nf) async { + final status = await getNullifierStatus(nf); + return MapEntry(nf, status.spent); + }); + + final entries = await Future.wait(futures); + results.addEntries(entries); + + return results; + } +} diff --git a/cw_pivx/lib/src/sapling/sapling_constants.dart b/cw_pivx/lib/src/sapling/sapling_constants.dart new file mode 100644 index 0000000000..c2ca80c88c --- /dev/null +++ b/cw_pivx/lib/src/sapling/sapling_constants.dart @@ -0,0 +1,222 @@ +/// PIVX Sapling protocol constants. PIVX Sapling follows Zcash Sapling with +/// PIVX-specific network parameters (notably the address HRP strings). +library; + +/// Sapling note commitment tree depth: a 32-level Merkle tree (2^32 leaves). +const int kSaplingTreeDepth = 32; + +/// Sapling extended spending key size in bytes. +const int kSaplingExtendedSpendingKeySize = 169; + +/// Sapling extended full viewing key size in bytes. +const int kSaplingExtendedFullViewingKeySize = 169; + +/// Sapling payment address size in bytes. +const int kSaplingPaymentAddressSize = 43; + +/// Sapling note plaintext size in bytes. +const int kSaplingNotePlaintextSize = 580; + +/// Sapling diversifier size in bytes. +const int kSaplingDiversifierSize = 11; + +abstract class PivxSaplingNetwork { + /// BIP-44 coin type for mainnet (used in key derivation). + static const int mainnetCoinType = 119; + + /// BIP-44 coin type for testnet (used in key derivation). + static const int testnetCoinType = 1; + + /// Payment address HRP (mainnet); addresses start with "ps". + static const String mainnetPaymentAddressHrp = 'ps'; + + /// Payment address HRP (testnet); addresses start with "ptestsapling". + static const String testnetPaymentAddressHrp = 'ptestsapling'; + + /// Extended spending key HRP (mainnet); starts with "p-secret-extended-key-main". + static const String mainnetExtendedSpendingKeyHrp = + 'p-secret-extended-key-main'; + + /// Extended spending key HRP (testnet); starts with "p-secret-extended-key-test". + static const String testnetExtendedSpendingKeyHrp = + 'p-secret-extended-key-test'; + + /// Full viewing key HRP (mainnet); keys start with "pviews". + static const String mainnetFullViewingKeyHrp = 'pviews'; + + /// Full viewing key HRP (testnet); keys start with "pviewtestsapling". + static const String testnetFullViewingKeyHrp = 'pviewtestsapling'; + + /// Incoming viewing key HRP (mainnet); keys start with "pivks". + static const String mainnetIncomingViewingKeyHrp = 'pivks'; + + /// Incoming viewing key HRP (testnet); keys start with "pivktestsapling". + static const String testnetIncomingViewingKeyHrp = 'pivktestsapling'; + + /// Mainnet Sapling activation height; shielded scanning starts here. + static const int mainnetSaplingActivationHeight = 2700500; + + /// Testnet Sapling activation height. Confirmed against PIVX Core v5.6.1 + /// `src/chainparams.cpp`. + static const int testnetSaplingActivationHeight = 201; + + /// Default shield-sync start: a buffer before activation so no tx is missed. + static const int mainnetDefaultStartingShieldBlock = 2700000; + + static const int testnetDefaultStartingShieldBlock = 201; +} + +/// Sapling proving-parameter files: one for spends, one for outputs (zk-SNARK). +abstract class SaplingParams { + static const String spendParamsFileName = 'sapling-spend.params'; + + static const String outputParamsFileName = 'sapling-output.params'; + + /// Expected SHA256 of sapling-spend.params; guards against corruption/tampering. + static const String spendParamsHash = + '8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13'; + + /// Expected SHA256 hash of sapling-output.params. + static const String outputParamsHash = + '2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4'; + + /// Size of sapling-spend.params file in bytes (approximately 47.5 MB). + static const int spendParamsSize = 47958396; + + /// Size of sapling-output.params file in bytes (approximately 3.6 MB). + static const int outputParamsSize = 3592860; + + /// URL for downloading sapling-spend.params (PIVX hosting). + static const String spendParamsUrl = + 'https://duddino.com/sapling-spend.params'; + + /// URL for downloading sapling-output.params (PIVX hosting). + static const String outputParamsUrl = + 'https://duddino.com/sapling-output.params'; +} + +/// PIVX transaction fee and dust policy shared by transparent and Sapling code. +/// +/// These values mirror PIVX Core's v5.6.1 relay policy: min relay fee of +/// 10,000 zatoshis/kB, dust relay fee of 30,000 zatoshis/kB, Sapling relay fee +/// factor of 100, transparent dust threshold of 5,460 zatoshis, and shielded +/// dust threshold of 1,446,000 zatoshis. +abstract class PivxFeePolicy { + static const int zatoshisPerPiv = 100000000; + static const int minRelayFeePerKb = 10000; + static const int dustRelayFeePerKb = 30000; + static const int saplingFeeFactor = 100; + static const int transparentDustThreshold = 5460; + static const int shieldedDustThreshold = 1446000; + static const int dustThreshold = transparentDustThreshold; + static const int maxReasonableFee = zatoshisPerPiv; + + static const int transparentInputSize = 148; + static const int transparentOutputSize = 34; + static const int transparentTxOverheadSize = 10; + + static const int saplingSpendSize = 384; + static const int saplingOutputSize = 948; + static const int saplingTxOverheadSize = 85; + + /// The Sapling builder (BundleType::Transactional, bundle_required) pads + /// shielded outputs to at least this many with zero-value dummy outputs, a + /// protocol privacy rule shared with PIVX Core. The fee must cover the padded + /// count or the node rejects the tx as insufficient fee. + static const int minShieldedOutputs = 2; + + /// Fixed non-count serialization bytes: version/type (4) + locktime (4) + + /// sapling-data flag (1) + valueBalance (8) + bindingSig (64). The four + /// CompactSize vector-count prefixes are added separately in [saplingTxSize]. + static const int saplingFixedOverheadSize = 81; + + /// CompactSize prefix length for a vector of [count] elements, matching PIVX + /// Core: 1 byte below 253, 3 bytes up to 65535. + static int compactSizeLength(int count) => + count < 0xfd ? 1 : (count <= 0xffff ? 3 : 5); + + static int feeForSize(int size, {int feePerKb = minRelayFeePerKb}) { + if (size <= 0) return minRelayFeePerKb; + final fee = (feePerKb * size + 999) ~/ 1000; + if (feePerKb == minRelayFeePerKb && fee < minRelayFeePerKb) { + return minRelayFeePerKb; + } + return fee; + } + + static int transparentTxSize(int inputsCount, int outputsCount) => + inputsCount * transparentInputSize + + outputsCount * transparentOutputSize + + transparentTxOverheadSize; + + static int saplingTxSize({ + int saplingInputs = 0, + int saplingOutputs = 0, + int transparentInputs = 0, + int transparentOutputs = 0, + }) { + // The builder pads shielded outputs to [minShieldedOutputs] with dummy + // outputs, so the wire tx has that many even with fewer real outputs. + final effectiveSaplingOutputs = saplingOutputs > minShieldedOutputs + ? saplingOutputs + : minShieldedOutputs; + // Fixed bytes + the four CompactSize vector-count prefixes. Below 253 + // elements each prefix is 1 byte and this equals saplingTxOverheadSize + // (85); at >=253 a prefix grows to 3 bytes so the estimate stays an exact + // upper bound on the real serialized size. The shielded fee is pinned to + // this exact size with no margin, so an under-estimate here is rejected by + // the network as "insufficient fee". + return saplingFixedOverheadSize + + compactSizeLength(transparentInputs) + + compactSizeLength(transparentOutputs) + + compactSizeLength(saplingInputs) + + compactSizeLength(effectiveSaplingOutputs) + + (saplingInputs * saplingSpendSize) + + (effectiveSaplingOutputs * saplingOutputSize) + + (transparentInputs * transparentInputSize) + + (transparentOutputs * transparentOutputSize); + } + + static int saplingFee({ + int saplingInputs = 0, + int saplingOutputs = 0, + int transparentInputs = 0, + int transparentOutputs = 0, + }) => + saplingFeeFactor * + feeForSize( + saplingTxSize( + saplingInputs: saplingInputs, + saplingOutputs: saplingOutputs, + transparentInputs: transparentInputs, + transparentOutputs: transparentOutputs, + ), + ); + + static bool isDust(int amount, {bool shielded = false}) => + amount > 0 && + amount < (shielded ? shieldedDustThreshold : transparentDustThreshold); +} + +/// Backwards-compatible Sapling fee facade. +abstract class SaplingFees { + static const int feePerKb = PivxFeePolicy.minRelayFeePerKb; + static const int saplingOutputSize = PivxFeePolicy.saplingOutputSize; + static const int saplingSpendSize = PivxFeePolicy.saplingSpendSize; + static const int transparentInputSize = PivxFeePolicy.transparentInputSize; + static const int transparentOutputSize = PivxFeePolicy.transparentOutputSize; + static const int txOverheadSize = PivxFeePolicy.saplingTxOverheadSize; + + static int calculateFee({ + int saplingInputs = 0, + int saplingOutputs = 0, + int transparentInputs = 0, + int transparentOutputs = 0, + }) => + PivxFeePolicy.saplingFee( + saplingInputs: saplingInputs, + saplingOutputs: saplingOutputs, + transparentInputs: transparentInputs, + transparentOutputs: transparentOutputs, + ); +} diff --git a/cw_pivx/lib/src/sapling/sapling_factories.dart b/cw_pivx/lib/src/sapling/sapling_factories.dart new file mode 100644 index 0000000000..216298c2e2 --- /dev/null +++ b/cw_pivx/lib/src/sapling/sapling_factories.dart @@ -0,0 +1,1744 @@ +/// Factories that wrap the native Sapling FFI implementations. + +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:convert'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_pivx/src/pivx_network.dart'; +import 'package:cw_pivx/src/sapling/native_sapling_key_manager.dart'; +import 'package:cw_pivx/src/sapling/native_shield_sync_engine.dart'; +import 'package:cw_pivx/src/sapling/pivx_sapling_electrumx.dart'; +import 'package:cw_pivx/src/sapling/sapling_constants.dart'; +import 'package:cw_pivx/src/sapling/sapling_note_storage.dart'; +import 'package:cw_pivx/src/sapling/sapling_ffi.dart' as ffi; +import 'package:cw_pivx/src/sapling/utils/atomic_tree_position.dart'; + +/// blocks to stay behind `db_height` on a node without `consistent_db_height`. +/// it can advance `db_height` a beat before a block's Sapling data is queryable, +/// so scanning at the ceiling hits a committed-but-empty block and skips a note. +/// stopgap; a consistent node drops it to 0. +const int _kSaplingIndexSafetyMargin = 3; + +class SaplingKeyManagerFactory { + static Future create({ + required Uint8List seed, + bool isTestnet = false, + int accountIndex = 0, + }) async { + final nativeManager = await NativeSaplingKeyManager.fromSeed( + seed, + isTestnet: isTestnet, + ); + return SaplingKeyManagerWrapper(nativeManager); + } +} + +/// Simpler wallet-facing wrapper around the native key manager. +class SaplingKeyManagerWrapper { + final NativeSaplingKeyManager _manager; + String? _defaultAddressCached; + int _nextIndex = 0; + + SaplingKeyManagerWrapper(this._manager); + + Future initialize() async { + _defaultAddressCached = await _manager.getDefaultAddress(); + } + + Future getDefaultAddress() async { + final encoded = _defaultAddressCached ?? await _manager.getDefaultAddress(); + return SaplingAddressResult(encoded: encoded); + } + + Future getAddressAtIndex(Uint8List indexBytes) async { + int index = 0; + for (int i = 0; i < 8 && i < indexBytes.length; i++) { + index |= indexBytes[i] << (i * 8); + } + final encoded = await _manager.deriveAddress(index); + return SaplingAddressResult(encoded: encoded); + } + + Future getNextAddress() async { + final encoded = await _manager.deriveAddress(_nextIndex); + _nextIndex++; + return SaplingAddressResult(encoded: encoded); + } + + Future deriveAddress(int index) async { + return await _manager.deriveAddress(index); + } + + Future getFullViewingKey() async { + return await _manager.getFullViewingKey(); + } + + bool validateAddress(String address) { + return _manager.validateAddress(address); + } + + void dispose() { + _manager.dispose(); + } +} + +class SaplingAddressResult { + final String encoded; + + SaplingAddressResult({required this.encoded}); +} + +class ShieldSyncEngineFactory { + static Future create({ + required SaplingKeyManagerWrapper keyManager, + required String walletId, + bool isTestnet = false, + required dynamic electrumClient, + required EncryptionFileUtils encryptionFileUtils, + required String password, + }) async { + final nativeEngine = NativeShieldSyncEngine(isTestnet: isTestnet); + final saplingClient = PIVXSaplingElectrumX( + electrumClient: electrumClient, + isTestnet: isTestnet, + ); + final storage = SaplingNoteStorage( + walletId: walletId, + isTestnet: isTestnet, + encryptionFileUtils: encryptionFileUtils, + password: password, + ); + await storage.load(); + + return ShieldSyncEngineWrapper( + nativeEngine, + electrumClient, + saplingClient, + keyManager: keyManager, + storage: storage, + isTestnet: isTestnet, + ); + } +} + +/// An incoming shielded payment seen in the mempool (0-conf). Display-only: +/// not a spendable note (no tree position/witness), can be dropped/replaced. +class MempoolIncomingNote { + final String txid; + final int value; + final int? firstSeen; + + MempoolIncomingNote({required this.txid, required this.value, this.firstSeen}); +} + +/// Result of a mempool peek. [truncated] true when the server hit its output +/// cap, so absence from [incoming] is not authoritative eviction and the caller +/// must merge rather than replace. +class MempoolScanResult { + final List incoming; + final bool truncated; + + MempoolScanResult(this.incoming, {this.truncated = false}); +} + +class ShieldSyncEngineWrapper { + final NativeShieldSyncEngine _engine; + final dynamic electrumClient; + final PIVXSaplingElectrumX saplingClient; + final SaplingKeyManagerWrapper keyManager; + final SaplingNoteStorage storage; + final bool isTestnet; + bool _isSyncing = false; + bool _stopRequested = false; + bool _treePositionIsTrusted = false; + // Node speaks display-order 32-byte hex (v1 hex_byte_order=display), so + // server nullifiers must be reversed to serialization order for on-device + // spend matching. Captured once per sync from the probed capabilities. + bool _usesDisplayByteOrder = false; + // throwaway decryptor for mempool peeking; isolated from _engine so 0-conf + // decrypts never touch the real note set, tree, or balance. + ffi.SaplingSyncEngine? _mempoolPeekEngine; + bool _mempoolUnsupportedLogged = false; + final AtomicTreePosition _treePosition = AtomicTreePosition(); + + ShieldSyncEngineWrapper( + this._engine, + this.electrumClient, + this.saplingClient, { + required this.keyManager, + required this.storage, + this.isTestnet = false, + }); + + int get nativeSyncHandle => _engine.handle; + + Future initialize() async { + await storage.load(); + _treePosition.initialize(storage.nextTreePosition); + _treePositionIsTrusted = storage.hasPersistedTreePosition; + + await restoreNotesFromStorage(); + } + + /// Restore notes from Dart storage into the native sync engine, which is + /// recreated empty on each app restart. + Future restoreNotesFromStorage() async { + final keyHandle = keyManager._manager.nativeKeys.handle; + final syncHandle = _engine.handle; + + printV('[PIVX Sapling] Restoring spendable notes from encrypted storage'); + + for (final note in storage.notes) { + if (note.isSpent) { + continue; + } + if (note.isPendingSpend) { + continue; + } + if (note.isProvisionallySpent) { + continue; // Quarantined by an unverified server-reported spend + } + if (!note.hasSpendingData) { + continue; + } + + final success = ffi.restoreNote( + keyHandle: keyHandle, + syncHandle: syncHandle, + noteData: note.toNativeRestoreJson(), + ); + + if (!success) { + printV('[PIVX Sapling] Failed to restore one stored note'); + } + } + + printV('[PIVX Sapling] Stored note restore pass complete'); + } + + /// Reset the native sync engine (rescan; clears in-memory state). + void resetNativeEngine() { + _engine.nativeEngine.reset(); + _treePosition.initialize(0); + _treePositionIsTrusted = false; + printV('[PIVX Sapling] Reset native sync engine'); + } + + int get balance => storage.spendableBalanceAt( + chainHeight: storage.lastSyncedHeight, + ); + + int balanceAt(int chainHeight) => storage.spendableBalanceAt( + chainHeight: chainHeight, + ); + + /// Nullifiers quarantined by unverified server-reported spends; a non-empty + /// list is a node-integrity warning signal for the wallet layer. + List get quarantinedNullifiers => storage.quarantinedNullifiers; + + int get pendingBalance => storage.pendingReceivedBalanceAt( + chainHeight: storage.lastSyncedHeight, + ); + + int pendingBalanceAt(int chainHeight) => storage.pendingReceivedBalanceAt( + chainHeight: chainHeight, + ); + + bool get isSyncing => _isSyncing; + + /// Cooperatively cancel an in-flight [startSync]: the block loop checks this + /// between rounds and returns early. Callers that need the engine idle (e.g. + /// a rescan about to reset it) should request the stop, then wait for + /// [isSyncing] to clear before mutating storage or the native engine. + void requestStop() => _stopRequested = true; + + /// Fetch Sapling blocks from the last synced height to the tip, trial-decrypt + /// outputs, update the tree/witnesses, and track spends. [startHeight] + /// defaults to last-synced or activation; [targetHeight] defaults to the tip. + Future startSync({ + int? startHeight, + int? targetHeight, + Uint8List? viewingKey, + required void Function(SyncStatus) onProgress, + }) async { + if (_isSyncing) { + return; + } + + _isSyncing = true; + _stopRequested = false; + + try { + final lastSyncedBlock = storage.lastSyncedHeight; + final activationHeight = saplingClient.activationHeight; + + int effectiveStartHeight; + if (startHeight != null) { + effectiveStartHeight = + startHeight < activationHeight ? activationHeight : startHeight; + } else { + effectiveStartHeight = lastSyncedBlock > activationHeight + ? lastSyncedBlock + 1 + : activationHeight; + } + + final capabilities = await saplingClient.probeCapabilities(); + _usesDisplayByteOrder = capabilities.usesDisplayByteOrder; + if (startHeight == null && + capabilities.supportsBlockHashes && + lastSyncedBlock >= activationHeight) { + final rewindHeight = await _detectReorgRewindHeight(lastSyncedBlock); + if (rewindHeight != null) { + await storage.rewindToHeight(rewindHeight); + resetNativeEngine(); + await restoreNotesFromStorage(); + effectiveStartHeight = rewindHeight >= activationHeight + ? rewindHeight + 1 + : activationHeight; + } + } + if (!storage.hasPersistedTreePosition && + effectiveStartHeight > activationHeight && + !capabilities.supportsGlobalOutputPositions) { + throw SaplingRpcException( + 'PIVX Sapling sync cannot start after activation without a persisted tree cursor or server global output positions', + ); + } + _treePositionIsTrusted = storage.hasPersistedTreePosition || + effectiveStartHeight <= activationHeight; + + onProgress(SyncStatus( + lastSyncedBlock: effectiveStartHeight, + chainTip: effectiveStartHeight, + blocksRemaining: 0, + progress: 0.0, + )); + + // cap the target at db_height minus a safety margin, always, including a + // header-triggered targetHeight (only the poll path capped before, so + // header syncs scanned at the tip). two reasons: + // 1. the index lags the tip; targeting the tip makes the top batches + // return index_incomplete and repoll/fail near the top. + // 2. db_height can advance a block or two before that block's sapling data + // is queryable via get_block_range. scanning at db_height then returns + // a complete-but-empty block that holds a note; each block is scanned + // once and the cursor advances past it, so the note is lost forever. + // staying [_kSaplingIndexSafetyMargin] behind guarantees every block is + // scanned once, after its data is committed. legacy nodes without an index + // status fall back to the header tip. + int effectiveTargetHeight = targetHeight ?? effectiveStartHeight; + // fresh db_height each pass; capabilities.indexHeight is cached at probe + // time and never moves, so it stalls the sync at the first ceiling (new + // receives never scanned). cached is fallback only. + final indexCeiling = + await saplingClient.fetchLiveIndexHeight() ?? capabilities.indexHeight; + if (indexCeiling != null) { + // stopgap for a node that advances db_height before a block's Sapling + // data is queryable: staying behind avoids scanning a committed-but-empty + // block and skipping a note. drops to 0 once it advertises + // consistent_db_height. + final margin = capabilities.supportsConsistentDbHeight + ? 0 + : _kSaplingIndexSafetyMargin; + effectiveTargetHeight = indexCeiling - margin; + } else if (targetHeight == null) { + try { + final tip = await electrumClient.getCurrentBlockChainTip(); + if (tip != null && tip > effectiveStartHeight) { + effectiveTargetHeight = tip as int; + } + } catch (e) { + // Fall back to start height, no sync will happen + } + } + + if (effectiveTargetHeight < effectiveStartHeight) { + onProgress(SyncStatus( + lastSyncedBlock: effectiveStartHeight, + chainTip: effectiveStartHeight, + blocksRemaining: 0, + progress: 1.0, + )); + return; + } + + printV( + '[PIVX Sapling] Sync starting at $effectiveStartHeight; target $effectiveTargetHeight'); + + var outputsChecked = 0; + var blocksWithSapling = 0; + + await saplingClient.syncBlocks( + fromHeight: effectiveStartHeight, + toHeight: effectiveTargetHeight, + batchSize: 100, // Max 100 blocks per request per server limit + // round-trip-bound over a recovery (2.85M blocks = ~28.5k requests), so + // concurrency is the lever. 5 -> 12; tune down if the v1 node drops the + // session under load. + parallelBatches: 12, // Parallel requests (network I/O remains parallel) + shouldCancel: () => _stopRequested, + onBatch: (blocks) async { + for (final block in blocks) { + await _processSingleBlock( + block, + keyManager, + storage, + (count) => outputsChecked += count, + () => blocksWithSapling++, + ); + } + }, + onRangeComplete: (rangeStart, rangeEnd, blockHashes) async { + final totalRange = effectiveTargetHeight - effectiveStartHeight + 1; + final safeTotalRange = totalRange < 1 ? 1 : totalRange; + final progress = + (rangeEnd - effectiveStartHeight + 1) / safeTotalRange; + final remaining = effectiveTargetHeight - rangeEnd; + final clampedProgress = progress.clamp(0.0, 1.0); + if (shouldLogPivxShieldSyncCheckpoint( + rangeStart: rangeStart, + rangeEnd: rangeEnd, + startHeight: effectiveStartHeight, + targetHeight: effectiveTargetHeight, + )) { + final percentage = (clampedProgress * 100).toStringAsFixed(2); + printV( + '[PIVX Sapling] Range complete $rangeStart-$rangeEnd; $remaining blocks remaining; $percentage%'); + } + onProgress(SyncStatus( + lastSyncedBlock: rangeEnd, + chainTip: effectiveTargetHeight, + blocksRemaining: remaining > 0 ? remaining : 0, + progress: clampedProgress, + )); + // Update storage sync height for empty ranges too, keeping the + // persisted tree cursor and height in the same sidecar write. + await storage.completeSyncRange( + lastSyncedHeight: rangeEnd, + nextTreePosition: _treePosition.current, + treePositionIsTrusted: _treePositionIsTrusted, + blockHashes: blockHashes, + ); + _engine.nativeEngine.setSyncHeight(rangeEnd); + }, + ); + + await storage.flushSync(); + + printV( + '[PIVX Sapling] Sync complete: checked $outputsChecked outputs in $blocksWithSapling blocks with Sapling txs'); + printV( + '[PIVX Sapling] Synced from $effectiveStartHeight to $effectiveTargetHeight'); + printV('[PIVX Sapling] Encrypted storage updated'); + + onProgress(SyncStatus( + lastSyncedBlock: effectiveTargetHeight, + chainTip: effectiveTargetHeight, + blocksRemaining: 0, + progress: 1.0, + )); + } finally { + _isSyncing = false; + } + } + + /// Process one block at a time in sequence, avoiding races in tree-position + /// assignment. + Future _processSingleBlock( + SaplingBlock block, + SaplingKeyManagerWrapper keyManager, + SaplingNoteStorage storage, + void Function(int count) onOutputsChecked, + void Function() onBlockWithSapling, + ) async { + final nativeKeys = keyManager._manager.nativeKeys; + final nativeEngine = _engine.nativeEngine; + + final outputs = block.txs.expand((tx) => tx.outputs).toList(); + final outputCount = outputs.length; + if (outputCount > 0) { + onBlockWithSapling(); + } + + final explicitPositionCount = + outputs.where((output) => output.globalPosition != null).length; + if (explicitPositionCount > 0 && explicitPositionCount != outputCount) { + throw SaplingRpcException( + 'PIVX Sapling block ${block.height} has partial output position data'); + } + + final hasExplicitPositions = explicitPositionCount == outputCount; + var currentPosition = _treePosition.current; + int? previousExplicitPosition; + var checkedExplicitCursor = false; + // a block that spends one of our notes persists that spend marker right away + // (recordObservedSpendByNullifier -> _save). force its height/hash checkpoint + // too, same reason as addedNote, so a crash + reorg can't leave a note stuck + // marked spent while the saved height still lags behind the spend. + var markedSpend = false; + + // Process spends (nullifiers) first: mark our notes as spent. + // Spends matching a locally broadcast transaction are terminal; unexpected + // server-reported spends are quarantined (provisionally spent, reversible + // by rescan) so a malicious server cannot irreversibly freeze funds. + for (final tx in block.txs) { + for (final spend in tx.spends) { + // Native notes and stored notes hold nullifiers in serialization order + // (Rust canonical). A display-order node reports spend nullifiers in + // display order, so reverse them before matching or our own spends are + // never detected. Non-display nodes are untouched (current behavior). + final nullifierBytes = _usesDisplayByteOrder + ? Uint8List.fromList(spend.nullifierBytes.reversed.toList()) + : spend.nullifierBytes; + final nullifierHex = _usesDisplayByteOrder + ? reverseSaplingHexBytes(spend.nullifier) + : spend.nullifier; + nativeEngine.checkNullifier(nullifierBytes); + final spentOurNote = await storage.recordObservedSpendByNullifier( + nullifierHex, + tx.txid, + spendingHeight: block.height, + ); + if (spentOurNote) markedSpend = true; + } + } + + var addedNote = false; + for (var txIdx = 0; txIdx < block.txs.length; txIdx++) { + final tx = block.txs[txIdx]; + for (var outIdx = 0; outIdx < tx.outputs.length; outIdx++) { + final output = tx.outputs[outIdx]; + onOutputsChecked(1); + final treePosition = output.globalPosition ?? currentPosition; + if (hasExplicitPositions) { + if (!checkedExplicitCursor && + _treePositionIsTrusted && + currentPosition > 0 && + treePosition != currentPosition) { + throw SaplingRpcException( + 'PIVX Sapling block ${block.height} output positions do not match the persisted tree cursor'); + } + checkedExplicitCursor = true; + _treePositionIsTrusted = true; + if (previousExplicitPosition != null && + treePosition != previousExplicitPosition + 1) { + throw SaplingRpcException( + 'PIVX Sapling block ${block.height} output positions are not contiguous'); + } + previousExplicitPosition = treePosition; + } + + // A display-order node emits the 32-byte cmu and epk big-endian + // (uint256 GetHex), but native trial decryption and note storage work + // in little-endian serialization order, so reverse both before the + // crypto boundary. The ciphertext is a raw byte blob and must not be + // touched. Same rule as the spend nullifiers above; non-display nodes + // are untouched. + final cmuBytes = _usesDisplayByteOrder + ? Uint8List.fromList(output.cmuBytes.reversed.toList()) + : output.cmuBytes; + final epkBytes = _usesDisplayByteOrder + ? Uint8List.fromList(output.epkBytes.reversed.toList()) + : output.epkBytes; + + final value = nativeEngine.tryDecryptOutput( + keys: nativeKeys, + cmu: cmuBytes, + epk: epkBytes, + encCiphertext: output.ciphertextBytes, + height: block.height, + txIndex: txIdx, + outputIndex: outIdx, + position: treePosition, + ); + + if (value > 0) { + // the note we just decrypted is stored at treePosition. grab that one + // note directly instead of serializing every note and scanning (was + // O(K^2) over a restore). + final fullNoteData = + ffi.getNoteAtPosition(nativeEngine.handle, treePosition); + if (fullNoteData == null) { + printV('[PIVX Sapling] Native note restore data unavailable'); + } + + final note = StoredSaplingNote( + id: '${tx.txid}:$outIdx', + value: value, + height: block.height, + blockTime: block.time, + txid: tx.txid, + outputIndex: outIdx, + treePosition: treePosition, + cmu: hex.encode(cmuBytes), + nullifier: fullNoteData?['nullifier'] as String?, + rseed: fullNoteData?['rseed'] as String?, + diversifier: fullNoteData?['diversifier'] as String?, + pkD: fullNoteData?['pk_d'] as String?, + address: fullNoteData?['address'] as String?, + memo: fullNoteData?['memo'] as String?, + txIndex: txIdx, + ); + await storage.addNote(note); + addedNote = true; + } + + currentPosition = treePosition + 1; + } + } + + await _treePosition.setAtLeast(currentPosition); + // a block that yielded a note must checkpoint its height+hash now, not on the + // 10k batch. addNote already wrote the note, so batching would leave the note + // ahead of its block on disk; a crash there plus a reorg of that block would + // orphan the note (reorg detection only looks back to the saved height). + await storage.completeSyncRange( + lastSyncedHeight: block.height, + nextTreePosition: currentPosition, + treePositionIsTrusted: _treePositionIsTrusted, + blockHashes: block.hash.isEmpty + ? const {} + : {block.height: block.hash}, + flush: addedNote || markedSpend, + ); + nativeEngine.setSyncHeight(block.height); + } + + Future _detectReorgRewindHeight(int lastSyncedBlock) async { + final activationHeight = saplingClient.activationHeight; + if (lastSyncedBlock < activationHeight) return null; + + final compareStart = lastSyncedBlock - 99 > activationHeight + ? lastSyncedBlock - 99 + : activationHeight; + final range = await saplingClient.getBlockRangeResult( + compareStart, + endHeight: lastSyncedBlock, + ); + if (range.blockHashes.isEmpty) return null; + + var firstMismatch = 0; + for (var height = compareStart; height <= lastSyncedBlock; height++) { + final localHash = storage.scannedBlockHashes[height]; + final serverHash = range.blockHashes[height]; + if (localHash == null || serverHash == null) { + continue; + } + if (localHash.toLowerCase() != serverHash.toLowerCase()) { + firstMismatch = height; + break; + } + } + + if (firstMismatch == 0) { + await storage.completeSyncRange( + lastSyncedHeight: lastSyncedBlock, + nextTreePosition: storage.nextTreePosition, + treePositionIsTrusted: storage.hasPersistedTreePosition, + blockHashes: range.blockHashes, + ); + return null; + } + + var rewindHeight = firstMismatch - 1; + for (var height = firstMismatch - 1; height >= activationHeight; height--) { + final localHash = storage.scannedBlockHashes[height]; + final serverHash = range.blockHashes[height]; + if (localHash != null && + serverHash != null && + localHash.toLowerCase() == serverHash.toLowerCase()) { + rewindHeight = height; + break; + } + } + if (rewindHeight < activationHeight) { + rewindHeight = activationHeight - 1; + } + + printV('[PIVX Sapling] Reorg detected; rewinding shielded sync state'); + return rewindHeight; + } + + /// Spent status for multiple nullifiers (nullifier hex -> spent). + Future> checkNullifiers(List nullifiers) async { + return await saplingClient.checkNullifiers(nullifiers); + } + + Future getBestAnchor() async { + return await saplingClient.getBestAnchor(); + } + + /// Clear stored notes and reset sync state for a rescan. + Future rescan({int? fromHeight}) async { + await storage.clear(); + _treePosition.reset(); + _treePositionIsTrusted = false; + } + + void stopSync() { + _isSyncing = false; + } + + /// Trial-decrypt the unconfirmed Sapling mempool for 0-conf incoming notes. + /// null when the snapshot is unavailable (caller keeps prior state); a + /// non-null list (possibly empty) replaces it. Display-only: decrypts against + /// a throwaway engine so nothing touches the real note set, tree, or balance. + /// Skips txids already in storage (mined/known) and our own sends (a spend + /// nullifier matching one of our notes means the outputs are change). + Future scanMempool() async { + final capabilities = await saplingClient.probeCapabilities(); + if (!capabilities.supportsMempool) { + if (!_mempoolUnsupportedLogged) { + _mempoolUnsupportedLogged = true; + printV('[PIVX Sapling] Mempool 0-conf not advertised by node'); + } + return null; + } + + final snapshot = await saplingClient.fetchMempool(); + if (snapshot == null) return null; + return decryptMempoolSnapshot(snapshot); + } + + /// Trial-decrypt a mempool snapshot (from a poll or a subscribe push) into our + /// 0-conf incoming notes. Isolated peek engine; same byte-order reversal, + /// txid dedup, and own-send suppression as the block scan. + Future decryptMempoolSnapshot( + SaplingMempoolResult snapshot) async { + final capabilities = await saplingClient.probeCapabilities(); + _usesDisplayByteOrder = capabilities.usesDisplayByteOrder; + if (snapshot.txs.isEmpty) { + // clear the throwaway state so the peek engine doesn't hold prior notes. + _mempoolPeekEngine?.reset(); + return MempoolScanResult(const []); + } + + final nativeKeys = keyManager._manager.nativeKeys; + final knownTxids = storage.notes.map((n) => n.txid).toSet(); + final myNullifiers = + storage.notes.map((n) => n.nullifier).whereType().toSet(); + + final peek = + _mempoolPeekEngine ??= ffi.SaplingSyncEngine(isTestnet: isTestnet); + // clear the prior cycle's throwaway notes so the sink stays bounded. + peek.reset(); + + final incoming = []; + var position = 0; + for (final tx in snapshot.txs) { + if (knownTxids.contains(tx.txid)) continue; // already mined or seen + + // our own send: a spend reveals one of our nullifiers, so its outputs are + // change coming back to us, not an incoming payment. + final isOwnSend = tx.spends.any((spend) { + final nfBytes = _usesDisplayByteOrder + ? Uint8List.fromList(spend.nullifierBytes.reversed.toList()) + : spend.nullifierBytes; + return myNullifiers.contains(hex.encode(nfBytes)); + }); + if (isOwnSend) continue; + + var txValue = 0; + for (final output in tx.outputs) { + // same crypto-boundary reversal as the block scan; value is + // position-independent so the sentinel position below is fine. + final cmuBytes = _usesDisplayByteOrder + ? Uint8List.fromList(output.cmuBytes.reversed.toList()) + : output.cmuBytes; + final epkBytes = _usesDisplayByteOrder + ? Uint8List.fromList(output.epkBytes.reversed.toList()) + : output.epkBytes; + final value = peek.tryDecryptOutput( + keys: nativeKeys, + cmu: cmuBytes, + epk: epkBytes, + encCiphertext: output.ciphertextBytes, + height: 0, + txIndex: 0, + outputIndex: 0, + position: position++, + ); + if (value > 0) txValue += value; + } + if (txValue > 0) { + incoming.add(MempoolIncomingNote( + txid: tx.txid, + value: txValue, + firstSeen: tx.firstSeen, + )); + } + } + if (snapshot.txs.isNotEmpty) { + printV( + '[PIVX Sapling] Mempool peek: ${snapshot.txs.length} tx in snapshot, ${incoming.length} ours'); + } + return MempoolScanResult(incoming, truncated: snapshot.truncated); + } + + void dispose() { + _engine.dispose(); + _mempoolPeekEngine?.dispose(); + _mempoolPeekEngine = null; + } +} + +bool shouldLogPivxShieldSyncCheckpoint({ + required int rangeStart, + required int rangeEnd, + required int startHeight, + required int targetHeight, + int checkpointInterval = 10000, +}) { + if (rangeStart <= startHeight) { + return true; + } + if (rangeEnd >= targetHeight) { + return true; + } + if (checkpointInterval <= 0) { + return false; + } + return rangeEnd % checkpointInterval == 0; +} + +class SyncStatus { + final int lastSyncedBlock; + final int chainTip; + final int blocksRemaining; + final double progress; + + SyncStatus({ + required this.lastSyncedBlock, + required this.chainTip, + required this.blocksRemaining, + required this.progress, + }); +} + +typedef SyncProgressCallback = void Function(SyncStatus status); + +class SaplingTransactionBuilderFactory { + static Future create({ + required SaplingKeyManagerWrapper keyManager, + required ShieldSyncEngineWrapper syncEngine, + bool isTestnet = false, + }) async { + return SaplingTransactionBuilderWrapper( + keyManager: keyManager, + syncEngine: syncEngine, + isTestnet: isTestnet, + ); + } +} + +class SaplingTransactionBuilderWrapper { + final SaplingKeyManagerWrapper keyManager; + final ShieldSyncEngineWrapper syncEngine; + final bool isTestnet; + String? _provingParamsPath; + bool _proverInitialized = false; + + SaplingTransactionBuilderWrapper({ + required this.keyManager, + required this.syncEngine, + required this.isTestnet, + }); + + bool get hasProvingParams => _provingParamsPath != null && _proverInitialized; + + String get provingParamsPath => _provingParamsPath ?? ''; + + /// Load the proving params (~50 MB, Groth16); downloaded/stored once. + Future loadProvingParams({required String path}) async { + if (!await hasLocalProvingParams(path)) { + throw Exception('Proving parameters not found at $path. ' + 'Call downloadProvingParams first.'); + } + + if (!ffi.initProver(path)) { + final error = ffi.getLastError(); + throw Exception('Failed to initialize prover: $error'); + } + + _provingParamsPath = path; + _proverInitialized = true; + } + + Future hasLocalProvingParams(String path) async { + final spendPath = '$path/sapling-spend.params'; + final outputPath = '$path/sapling-output.params'; + + return await _verifyParamFile( + file: File(spendPath), + expectedSize: SaplingParams.spendParamsSize, + expectedHash: SaplingParams.spendParamsHash, + ) && + await _verifyParamFile( + file: File(outputPath), + expectedSize: SaplingParams.outputParamsSize, + expectedHash: SaplingParams.outputParamsHash, + ); + } + + /// Provision proving params from the bundled Flutter asset. + /// + /// Returns true when both params were copied out of the app bundle and pass + /// SHA256 verification against [SaplingParams]. Returns false when the bundle + /// carries no params (a build compiled WITHOUT the ~51MB asset), so the + /// caller falls back to the network download. Only an absent/empty bundle + /// returns false; a present-but-corrupt bundle throws (funds-critical). + Future copyProvingParamsFromBundle(String path) async { + final spend = await loadBundledParamOrNull( + 'packages/cw_pivx/assets/params/${SaplingParams.spendParamsFileName}'); + final output = await loadBundledParamOrNull( + 'packages/cw_pivx/assets/params/${SaplingParams.outputParamsFileName}'); + + // No bundled params in this build, let the caller download them. + if (spend == null || output == null) return false; + + final dir = Directory(path); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + await writeAndVerifyProvingParam( + bytes: spend, + destination: '$path/${SaplingParams.spendParamsFileName}', + expectedSize: SaplingParams.spendParamsSize, + expectedHash: SaplingParams.spendParamsHash, + ); + await writeAndVerifyProvingParam( + bytes: output, + destination: '$path/${SaplingParams.outputParamsFileName}', + expectedSize: SaplingParams.outputParamsSize, + expectedHash: SaplingParams.outputParamsHash, + ); + _provingParamsPath = path; + return true; + } + + /// Load a bundled param asset, returning null when it is absent or empty + /// (a build without bundled params). Never throws on absence. + static Future loadBundledParamOrNull(String assetKey) async { + try { + final data = await rootBundle.load(assetKey); + if (data.lengthInBytes == 0) return null; + return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + } catch (_) { + return null; + } + } + + /// Write [bytes] to [destination] and verify the written file's size and + /// SHA256 against the expected values. Throws (and removes the file) on + /// mismatch so a bad param file is never left behind. + static Future writeAndVerifyProvingParam({ + required Uint8List bytes, + required String destination, + required int expectedSize, + required String expectedHash, + }) async { + final file = File(destination); + await file.writeAsBytes(bytes, flush: true); + if (!await _verifyParamFile( + file: file, + expectedSize: expectedSize, + expectedHash: expectedHash, + )) { + if (await file.exists()) { + await file.delete(); + } + throw Exception( + 'PIVX Sapling bundled proving parameter verification failed for $destination'); + } + } + + /// Download proving parameters from PIVX servers to [path]. + Future downloadProvingParams({ + required String path, + required void Function(double) onProgress, + }) async { + await downloadProvingParamsToPath(path: path, onProgress: onProgress); + _provingParamsPath = path; + } + + static Future downloadProvingParamsToPath({ + required String path, + required void Function(double) onProgress, + String spendParamsUrl = SaplingParams.spendParamsUrl, + int spendParamsSize = SaplingParams.spendParamsSize, + String spendParamsHash = SaplingParams.spendParamsHash, + String outputParamsUrl = SaplingParams.outputParamsUrl, + int outputParamsSize = SaplingParams.outputParamsSize, + String outputParamsHash = SaplingParams.outputParamsHash, + }) async { + final expectedTotalSize = spendParamsSize + outputParamsSize; + + final dir = Directory(path); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + var downloadedBytes = 0; + + await _downloadParamIfNeeded( + url: spendParamsUrl, + destination: '$path/${SaplingParams.spendParamsFileName}', + expectedSize: spendParamsSize, + expectedHash: spendParamsHash, + onDownloaded: (bytes) { + downloadedBytes = bytes; + onProgress(downloadedBytes / expectedTotalSize); + }, + ); + downloadedBytes = spendParamsSize; + onProgress(downloadedBytes / expectedTotalSize); + + await _downloadParamIfNeeded( + url: outputParamsUrl, + destination: '$path/${SaplingParams.outputParamsFileName}', + expectedSize: outputParamsSize, + expectedHash: outputParamsHash, + onDownloaded: (bytes) { + onProgress((downloadedBytes + bytes) / expectedTotalSize); + }, + ); + + onProgress(1.0); + } + + static Future _downloadParamIfNeeded({ + required String url, + required String destination, + required int expectedSize, + required String expectedHash, + required void Function(int bytesDownloaded) onDownloaded, + }) async { + final destinationFile = File(destination); + if (await _verifyParamFile( + file: destinationFile, + expectedSize: expectedSize, + expectedHash: expectedHash, + )) { + onDownloaded(expectedSize); + return; + } + + if (await destinationFile.exists()) { + await destinationFile.delete(); + } + + await _downloadFileAtomically( + url: url, + destination: destination, + expectedSize: expectedSize, + expectedHash: expectedHash, + onProgress: onDownloaded, + ); + } + + static Future _verifyParamFile({ + required File file, + required int expectedSize, + required String expectedHash, + }) async { + try { + if (!await file.exists()) return false; + + final size = await file.length(); + if (size != expectedSize) return false; + + final hash = await _sha256File(file); + return hash == expectedHash; + } catch (_) { + return false; + } + } + + static Future _sha256File(File file) async { + final digestSink = AccumulatorSink(); + final input = sha256.startChunkedConversion(digestSink); + await for (final chunk in file.openRead()) { + input.add(chunk); + } + input.close(); + return digestSink.events.single.toString(); + } + + /// Download a file through Cake's proxy/Tor wrapper, verify it, then rename. + static Future _downloadFileAtomically({ + required String url, + required String destination, + required int expectedSize, + required String expectedHash, + required void Function(int bytesDownloaded) onProgress, + }) async { + final destinationFile = File(destination); + final tempFile = File('$destination.download'); + final uri = Uri.parse(url); + + if (await tempFile.exists()) { + await tempFile.delete(); + } + + final client = CakeTor.instance == null + ? HttpClient() + // ignore: deprecated_member_use + : ProxyWrapper().getHttpClient(internal: true); + IOSink? output; + final digestSink = AccumulatorSink(); + final hashInput = sha256.startChunkedConversion(digestSink); + var downloadedBytes = 0; + + try { + final request = await client.getUrl(uri); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw Exception( + 'PIVX Sapling proving parameter download failed with HTTP ${response.statusCode}'); + } + + output = tempFile.openWrite(); + await for (final chunk in response) { + downloadedBytes += chunk.length; + hashInput.add(chunk); + output.add(chunk); + onProgress(downloadedBytes); + } + await output.flush(); + await output.close(); + output = null; + } catch (_) { + try { + await output?.close(); + } catch (_) {} + if (await tempFile.exists()) { + await tempFile.delete(); + } + rethrow; + } finally { + hashInput.close(); + client.close(force: true); + } + + if (downloadedBytes != expectedSize) { + await tempFile.delete(); + throw Exception( + 'PIVX Sapling proving parameter size mismatch after download'); + } + + final hash = digestSink.events.single.toString(); + if (hash != expectedHash) { + await tempFile.delete(); + throw Exception( + 'PIVX Sapling proving parameter hash mismatch after download'); + } + + if (!await _verifyParamFile( + file: tempFile, + expectedSize: expectedSize, + expectedHash: expectedHash, + )) { + await tempFile.delete(); + throw Exception( + 'PIVX Sapling proving parameter verification failed after write'); + } + + if (await destinationFile.exists()) { + await destinationFile.delete(); + } + await tempFile.rename(destination); + } + + /// Build a transaction spending shielded notes. + /// + /// A Sapling destination produces a z-to-z transaction; a PIVX transparent + /// destination produces a z-to-t (deshield) transaction with a transparent + /// payment output and shielded change. + Future buildTransaction({ + required SaplingTransactionOptions options, + Set reservedNullifiers = const {}, + }) async { + final isShieldedDestination = keyManager.validateAddress(options.toAddress); + if (!isShieldedDestination) { + // Loose client-side shape check; the native builder performs the + // strict base58check + network-prefix validation and fails closed. + // PivxNetwork.isValidAddress only knows mainnet prefixes, so testnet + // destinations are length-checked here and fully validated natively. + final address = options.toAddress; + final looksValidTransparent = isTestnet + ? address.length >= 26 && address.length <= 36 + : PivxNetwork.isValidAddress(address) && !address.startsWith('ps'); + if (!looksValidTransparent) { + throw Exception('Invalid destination address'); + } + if (options.memo != null && options.memo!.isNotEmpty) { + throw Exception( + 'PIVX memos are not supported for transparent destinations'); + } + } + + final dustFloor = isShieldedDestination + ? PivxFeePolicy.shieldedDustThreshold + : PivxFeePolicy.transparentDustThreshold; + if (options.amount < dustFloor) { + throw Exception(isShieldedDestination + ? 'Amount below PIVX shielded dust threshold' + : 'Amount below PIVX transparent dust threshold'); + } + + if (syncEngine.balance < options.amount) { + throw Exception('Insufficient shielded balance'); + } + + if (!hasProvingParams) { + throw Exception( + 'Proving parameters not loaded. Call loadProvingParams first.'); + } + + final syncHandle = syncEngine.nativeSyncHandle; + final spendChainHeight = syncEngine.storage.lastSyncedHeight; + final spendEligibility = syncEngine.storage.spendEligibilitySummaryAt( + chainHeight: spendChainHeight, + ); + printV( + '[PIVX Sapling] Shielded spend eligibility: ${spendEligibility.sanitizedLogLine}'); + + final spendableNullifiers = syncEngine.storage + .spendableNotesAt( + chainHeight: spendChainHeight, + ) + .map((note) => note.nullifier) + .whereType() + .toSet(); + final allNotes = ffi + .getSpendableNotes(syncHandle) + .where((note) => spendableNullifiers.contains(note['nullifier'])) + .where((note) => !reservedNullifiers.contains(note['nullifier'])) + .toList(); + + if (allNotes.isEmpty) { + throw Exception( + 'No spendable shielded notes available at required confirmations (${PivxShieldedConfirmationPolicy.spendConfirmations})'); + } + + // Select notes, then verify none is already spent on-chain before the + // expensive proof. The local view can lag the node, and the same seed may + // have spent these notes in another wallet (e.g. PIVX Core), so a locally- + // "unspent" note may already be spent. Drop any spent note, mark it spent + // (fixes the stale balance), and reselect so we pick spendable notes that + // cover the amount, instead of building a tx the node rejects as + // bad-txns-shielded-requirements-not-met after a 30-60s proof. Bounded so a + // misbehaving node can't loop us forever. + final nodeUsesDisplay = (await syncEngine.saplingClient.probeCapabilities()) + .usesDisplayByteOrder; + List> selectedNotes = const []; + var selectionVerified = false; + for (var attempt = 0; attempt < 6; attempt++) { + selectedNotes = selectNotesForAmount( + allNotes, + options.amount, + spendAll: options.spendAllShieldedInputs, + transparentDestination: !isShieldedDestination, + ); + if (selectedNotes.isEmpty) { + throw Exception('Could not select sufficient notes'); + } + + final selectedNullifiers = { + for (final n in selectedNotes) + if (n['nullifier'] is String) n['nullifier'] as String, + }; + if (selectedNullifiers.isEmpty) { + selectionVerified = true; + break; + } + + // Notes hold nullifiers in serialization order; the node indexes them in + // display order, so reverse for the query and map the result back. + final queryToStored = { + for (final nf in selectedNullifiers) + (nodeUsesDisplay ? reverseSaplingHexBytes(nf) : nf): nf, + }; + final spentStatus = await syncEngine.saplingClient + .checkNullifiers(queryToStored.keys.toList()); + final alreadySpent = { + for (final entry in spentStatus.entries) + if (entry.value && queryToStored.containsKey(entry.key)) + queryToStored[entry.key]!, + }; + if (alreadySpent.isEmpty) { + selectionVerified = true; + break; // every selected note is spendable on-chain + } + + for (final nf in alreadySpent) { + await syncEngine.storage.markSpentByNullifier(nf, 'external-spend'); + } + allNotes.removeWhere((n) => alreadySpent.contains(n['nullifier'])); + } + if (!selectionVerified || selectedNotes.isEmpty) { + throw Exception( + 'Could not assemble a spendable set of shielded notes (some were ' + 'already spent on-chain). The balance has been updated. Resync and ' + 'try again.'); + } + + final totalInput = + selectedNotes.fold(0, (sum, n) => sum + (n['value'] as int)); + final spendPlan = planShieldedSpend( + totalInput: totalInput, + amount: options.amount, + saplingInputs: selectedNotes.length, + transparentDestination: !isShieldedDestination, + ); + final fee = spendPlan.fee; + + // Verify we have enough after fee + if (!spendPlan.canBuild || totalInput < options.amount + fee) { + throw Exception('Insufficient balance after fee'); + } + + printV('[PIVX Sapling] Shielded note selection complete'); + + // A shielded spend needs a canonical Merkle witness. Fail closed against + // nodes that cannot provide one rather than building an unspendable tx. + final capabilities = await syncEngine.saplingClient.probeCapabilities(); + if (!capabilities.canonicalWitnesses) { + throw Exception( + 'PIVX shielded send unavailable: this node cannot provide canonical witnesses'); + } + final usesDisplay = capabilities.usesDisplayByteOrder; + + // Get current anchor once and require every witness to be bound to it. + printV('[PIVX Sapling] Getting anchor...'); + final anchorResult = await syncEngine.getBestAnchor(); + printV('[PIVX Sapling] Got spend anchor'); + + printV('[PIVX Sapling] Fetching witnesses...'); + final notesWithWitnesses = + await _fetchWitnesses(selectedNotes, anchorResult, usesDisplay); + printV('[PIVX Sapling] Witnesses fetched'); + final witnessSources = notesWithWitnesses + .map((note) => note['witness_source'] as String?) + .whereType() + .toList(growable: false); + final witnessSourceSummary = + witnessSources.isEmpty ? 'none' : witnessSources.toSet().join(','); + printV('[PIVX Sapling] Witness source summary: $witnessSourceSummary'); + if (witnessSources + .contains(SaplingWitnessResult.sourceCommitmentOnlyFallback)) { + printV( + '[PIVX Sapling] Witness fallback used; anchor-bound ElectrumX release gate remains open'); + } + final spendAnchor = _spendAnchorForWitnesses( + notesWithWitnesses, + ) ?? + anchorResult.anchor; + if (spendAnchor.toLowerCase() != anchorResult.anchor.toLowerCase()) { + printV('[PIVX Sapling] Using witness-returned spend anchor'); + } + // The prover decodes anchorHex with Anchor::from_bytes and compares the + // recomputed witness root (from the serialization-order path + internal + // note cmu) to it, so the anchor must be serialization order. spendAnchor + // is display order on a display node; reverse it. The per-note cmu in the + // notes JSON is already Rust serialization order and is left untouched. + final proverAnchor = + usesDisplay ? reverseSaplingHexBytes(spendAnchor) : spendAnchor; + + final keyHandle = keyManager._manager.nativeKeys.handle; + + final notesJson = jsonEncode(notesWithWitnesses); + printV('[PIVX Sapling] Building shielded transaction'); + + printV( + '[PIVX Sapling] Calling FFI buildShieldedTransaction (this may take 30-60 seconds for proving)...'); + final result = ffi.buildShieldedTransaction( + keyHandle: keyHandle, + notesJson: notesJson, + toAddress: options.toAddress, + amount: options.amount, + memo: options.memo, + fee: fee, + anchorHex: proverAnchor, + ); + printV('[PIVX Sapling] FFI transaction build returned'); + + if (result['status'] == 'error') { + final nativeError = result['error']?.toString(); + final suffix = + nativeError == null || nativeError.isEmpty ? '' : ': $nativeError'; + printV('[PIVX Sapling] Native transaction build failed$suffix'); + throw Exception('PIVX shielded transaction build failed$suffix'); + } + + final txHex = result['tx_hex'] as String; + final txid = result['txid'] as String; + + return SaplingTransactionResult( + rawTx: Uint8List.fromList(hex.decode(txHex)), + txHex: txHex, + txId: txid, + fee: fee, + spentNullifiers: selectedNotes + .map((note) => note['nullifier'] as String?) + .whereType() + .toList(growable: false), + witnessSources: witnessSources, + ); + } + + /// Build a transparent-to-shielded (t-to-z, shield) transaction. + /// + /// [utxos] entries carry txid, vout, value, script_pubkey and private_key + /// exactly as required by the native builder, which re-verifies the key + /// against the script hash and fails closed. + Future buildShieldTransaction({ + required List> utxos, + required String toAddress, + required int amount, + String? memo, + required int fee, + String? changeAddress, + int change = 0, + }) async { + if (!keyManager.validateAddress(toAddress)) { + throw Exception('Shield destination must be a Sapling address'); + } + if (amount < PivxFeePolicy.shieldedDustThreshold) { + throw Exception('Amount below PIVX shielded dust threshold'); + } + if (utxos.isEmpty) { + throw Exception('No transparent UTXOs selected'); + } + if (!hasProvingParams) { + throw Exception( + 'Proving parameters not loaded. Call loadProvingParams first.'); + } + + final keyHandle = keyManager._manager.nativeKeys.handle; + final utxosJson = jsonEncode(utxos); + printV('[PIVX Sapling] Building shield (t-to-z) transaction'); + final result = ffi.buildShieldTransaction( + keyHandle: keyHandle, + utxosJson: utxosJson, + toAddress: toAddress, + amount: amount, + memo: memo, + fee: fee, + changeAddress: changeAddress, + change: change, + ); + + if (result['status'] == 'error') { + final nativeError = result['error']?.toString(); + final suffix = + nativeError == null || nativeError.isEmpty ? '' : ': $nativeError'; + printV('[PIVX Sapling] Native shield transaction build failed$suffix'); + throw Exception('PIVX shield transaction build failed$suffix'); + } + + final txHex = result['tx_hex'] as String; + final txid = result['txid'] as String; + return SaplingTransactionResult( + rawTx: Uint8List.fromList(hex.decode(txHex)), + txHex: txHex, + txId: txid, + fee: (result['fee'] as num?)?.toInt() ?? fee, + ); + } + + /// Plan the fee and transparent change for a t-to-z shield spend. + /// + /// The destination is one Sapling output; change (if any) returns to a + /// transparent change address. Dust change is absorbed into the fee using + /// the transparent dust threshold. + static ShieldedSpendPlan planShieldSpend({ + required int totalInput, + required int amount, + required int transparentInputs, + }) { + final noChangeFee = PivxFeePolicy.saplingFee( + saplingInputs: 0, + saplingOutputs: 1, + transparentInputs: transparentInputs, + ); + + if (totalInput < amount + noChangeFee) { + return ShieldedSpendPlan(fee: noChangeFee, change: 0, canBuild: false); + } + + final noChangeRemainder = totalInput - amount - noChangeFee; + if (noChangeRemainder <= PivxFeePolicy.transparentDustThreshold) { + return ShieldedSpendPlan( + fee: noChangeFee + noChangeRemainder, + change: 0, + canBuild: true, + ); + } + + final withChangeFee = PivxFeePolicy.saplingFee( + saplingInputs: 0, + saplingOutputs: 1, + transparentInputs: transparentInputs, + transparentOutputs: 1, + ); + if (totalInput < amount + withChangeFee) { + return ShieldedSpendPlan(fee: withChangeFee, change: 0, canBuild: false); + } + + final change = totalInput - amount - withChangeFee; + if (change <= PivxFeePolicy.transparentDustThreshold) { + return ShieldedSpendPlan( + fee: withChangeFee + change, + change: 0, + canBuild: true, + ); + } + + return ShieldedSpendPlan( + fee: withChangeFee, change: change, canBuild: true); + } + + /// Select notes to cover the required amount plus its fee. + static List> selectNotesForAmount( + List> allNotes, + int amount, { + bool spendAll = false, + bool transparentDestination = false, + }) { + // Sort by value descending to minimize number of inputs + final sorted = List>.from(allNotes) + ..sort((a, b) => (b['value'] as int).compareTo(a['value'] as int)); + + if (spendAll) { + return sorted; + } + + final selected = >[]; + var total = 0; + + for (final note in sorted) { + selected.add(note); + total += note['value'] as int; + if (planShieldedSpend( + totalInput: total, + amount: amount, + saplingInputs: selected.length, + transparentDestination: transparentDestination, + ).canBuild) { + break; + } + } + + return selected; + } + + /// Plan the fee and change for a shielded spend. + /// + /// A shielded destination (z-to-z) pays one Sapling output plus optional + /// Sapling change; a transparent destination (z-to-t) pays one transparent + /// output plus optional Sapling change. Change always stays shielded, so + /// dust-change absorption always uses the shielded dust threshold. + static ShieldedSpendPlan planShieldedSpend({ + required int totalInput, + required int amount, + required int saplingInputs, + bool transparentDestination = false, + }) { + final destinationSaplingOutputs = transparentDestination ? 0 : 1; + final destinationTransparentOutputs = transparentDestination ? 1 : 0; + + final noChangeFee = PivxFeePolicy.saplingFee( + saplingInputs: saplingInputs, + saplingOutputs: destinationSaplingOutputs, + transparentOutputs: destinationTransparentOutputs, + ); + + if (totalInput < amount + noChangeFee) { + return ShieldedSpendPlan(fee: noChangeFee, change: 0, canBuild: false); + } + + final noChangeRemainder = totalInput - amount - noChangeFee; + if (noChangeRemainder <= PivxFeePolicy.shieldedDustThreshold) { + return ShieldedSpendPlan( + fee: noChangeFee + noChangeRemainder, + change: 0, + canBuild: true, + ); + } + + final withChangeFee = PivxFeePolicy.saplingFee( + saplingInputs: saplingInputs, + saplingOutputs: destinationSaplingOutputs + 1, + transparentOutputs: destinationTransparentOutputs, + ); + if (totalInput < amount + withChangeFee) { + return ShieldedSpendPlan( + fee: withChangeFee, + change: 0, + canBuild: false, + ); + } + + final change = totalInput - amount - withChangeFee; + if (change <= PivxFeePolicy.shieldedDustThreshold) { + return ShieldedSpendPlan( + fee: withChangeFee + change, + change: 0, + canBuild: true, + ); + } + + return ShieldedSpendPlan( + fee: withChangeFee, + change: change, + canBuild: true, + ); + } + + /// Fetch merkle witnesses for notes from ElectrumX. + /// + /// Uses blockchain.sapling.get_witness RPC: + /// - commitment_hex: 32-byte commitment (cmu) as hex + /// - anchor_height: Block height of anchor + /// + /// Returns: {position, path, anchor, commitment, commitment_height} + Future>> _fetchWitnesses( + List> notes, + BestAnchorResult anchorResult, + bool usesDisplay, + ) async { + final result = >[]; + + for (final note in notes) { + final noteWithWitness = Map.from(note); + + try { + String? cmu = note['cmu'] as String?; + + if (cmu == null || cmu.isEmpty) { + printV('[PIVX] Note missing cmu, cannot fetch witness'); + noteWithWitness['witness'] = ''; + noteWithWitness['witness_position'] = 0; + result.add(noteWithWitness); + continue; + } + + // Fetch witness from ElectrumX and require it to match the selected + // anchor that will be passed into FFI signing. The stored cmu is Rust + // serialization order; a display-order node indexes and echoes + // commitments in display order, so request in display order. The + // note's own 'cmu' entry (serialization) is left untouched for the + // prover's per-note cmu check. + final requestCommitment = + usesDisplay ? reverseSaplingHexBytes(cmu) : cmu; + printV('[PIVX] Fetching shielded witness'); + final witness = await syncEngine.saplingClient.getAnchorBoundWitness( + commitment: requestCommitment, + anchor: anchorResult, + notePosition: note['position'] as int?, + ); + + printV('[PIVX] Got shielded witness response'); + // Serialize path as hex-encoded concatenated hashes for the current + // FFI transaction builder contract. + final witnessHex = witness.path.join(''); + final firstPathLength = + witness.path.isEmpty ? 0 : witness.path.first.length; + final isHexPath = RegExp(r'^[0-9a-fA-F]+$').hasMatch(witnessHex); + printV( + '[PIVX Sapling] Witness path shape: count=${witness.path.length}, first_chars=$firstPathLength, total_chars=${witnessHex.length}, hex=$isHexPath'); + noteWithWitness['witness'] = witnessHex; + noteWithWitness['witness_position'] = witness.position; + noteWithWitness['anchor'] = witness.anchor; + noteWithWitness['anchor_height'] = witness.anchorHeight; + noteWithWitness['witness_source'] = witness.source; + } catch (e) { + printV('[PIVX] Failed to fetch witness'); + rethrow; // Don't continue with missing witness data + } + + result.add(noteWithWitness); + } + + return result; + } + + String? _spendAnchorForWitnesses(List> notes) { + String? anchor; + for (final note in notes) { + final noteAnchor = note['anchor'] as String?; + if (noteAnchor == null || noteAnchor.isEmpty) { + continue; + } + if (anchor == null) { + anchor = noteAnchor; + continue; + } + if (anchor.toLowerCase() != noteAnchor.toLowerCase()) { + throw SaplingRpcException( + 'PIVX Sapling witnesses returned inconsistent anchors'); + } + } + return anchor; + } + + void dispose() { + if (_proverInitialized) { + ffi.disposeProver(); + _proverInitialized = false; + } + } +} + +class SaplingTransactionOptions { + final String toAddress; + final int amount; + final String? memo; + final bool useShieldedInputs; + final bool spendAllShieldedInputs; + + SaplingTransactionOptions({ + required this.toAddress, + required this.amount, + this.memo, + this.useShieldedInputs = true, + this.spendAllShieldedInputs = false, + }); +} + +class ShieldedSpendPlan { + ShieldedSpendPlan({ + required this.fee, + required this.change, + required this.canBuild, + }); + + final int fee; + final int change; + final bool canBuild; +} + +class SaplingTransactionResult { + final Uint8List rawTx; + final String txHex; + final String txId; + final int fee; + final List spentNullifiers; + final List witnessSources; + + SaplingTransactionResult({ + required this.rawTx, + required this.txHex, + required this.txId, + required this.fee, + this.spentNullifiers = const [], + this.witnessSources = const [], + }); +} diff --git a/cw_pivx/lib/src/sapling/sapling_ffi.dart b/cw_pivx/lib/src/sapling/sapling_ffi.dart new file mode 100644 index 0000000000..1b229469ba --- /dev/null +++ b/cw_pivx/lib/src/sapling/sapling_ffi.dart @@ -0,0 +1,995 @@ +/// Dart FFI bindings to the native Rust PIVX Sapling library. + +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:ffi/ffi.dart'; +import 'package:cw_pivx/src/sapling/sapling_constants.dart'; + +/// FFI buffer structure matching Rust's FFIBuffer. +class FFIBuffer extends Struct { + external Pointer data; + + @Size() + external int len; +} + +DynamicLibrary _loadLibrary() { + final overridePath = Platform.environment['PIVX_SAPLING_LIBRARY_PATH']; + if (overridePath != null && overridePath.isNotEmpty) { + return DynamicLibrary.open(overridePath); + } + + if (Platform.isAndroid) { + return DynamicLibrary.open('libcw_pivx_sapling.so'); + } else if (Platform.isIOS) { + // iOS: Rust static lib is force-loaded into cw_pivx.framework + try { + final lib = DynamicLibrary.open('cw_pivx.framework/cw_pivx'); + lib.lookup('cw_pivx_version'); + return lib; + } catch (e) { + // symbols may instead be linked into the main binary + return DynamicLibrary.process(); + } + } else if (Platform.isMacOS) { + return _openFirstAvailableLibraryPath(const [ + 'libcw_pivx_sapling.dylib', + 'cw_pivx/macos/Frameworks/libcw_pivx_sapling.dylib', + '../cw_pivx/macos/Frameworks/libcw_pivx_sapling.dylib', + 'macos/Frameworks/libcw_pivx_sapling.dylib', + ]); + } else if (Platform.isLinux) { + return DynamicLibrary.open('libcw_pivx_sapling.so'); + } else if (Platform.isWindows) { + return DynamicLibrary.open('cw_pivx_sapling.dll'); + } + throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}'); +} + +DynamicLibrary _openFirstAvailableLibraryPath(List paths) { + Object? lastError; + for (final path in paths) { + try { + return DynamicLibrary.open(path); + } catch (error) { + lastError = error; + } + } + + throw StateError( + 'Unable to load native PIVX Sapling library from ${paths.join(', ')}' + '${lastError == null ? '' : ': $lastError'}', + ); +} + +late final DynamicLibrary _nativeLib; +bool _nativeLibLoaded = false; +String? _nativeLibError; +String? _nativeSelfTestError; + +bool get isSaplingFFIAvailable { + _ensureLoaded(); + return _nativeLibLoaded; +} + +String? get saplingFFIError => _nativeLibError; + +String? get saplingFFISelfTestError => _nativeSelfTestError; + +class SaplingNativeSelfTestResult { + const SaplingNativeSelfTestResult({ + required this.loaded, + required this.symbolsReady, + required this.feeMatchesPolicy, + this.version, + this.error, + }); + + final bool loaded; + final bool symbolsReady; + final bool feeMatchesPolicy; + final String? version; + final String? error; + + bool get passed => + loaded && symbolsReady && feeMatchesPolicy && error == null; +} + +void _ensureLoaded() { + if (_nativeLibLoaded) return; + try { + _nativeLib = _loadLibrary(); + _nativeLibLoaded = true; + } catch (e) { + _nativeLibError = e.toString(); + } +} + +String _nativeUnavailableMessage() => + 'Native library not available: ${_nativeLibError ?? 'unknown load error'}'; + +/// Overwrite a native byte buffer before freeing it. Best-effort memory hygiene +/// for short-lived FFI copies of key material (e.g. BIP39 seed bytes); makes no +/// guarantee about allocator copies, paging, crash dumps, or Rust-owned data. +void zeroNativeUint8Buffer(Pointer pointer, int length) { + if (pointer == nullptr || length <= 0) return; + + pointer.asTypedList(length).fillRange(0, length, 0); +} + +/// Overwrite a native UTF-8 string before freeing it. [value] recovers the +/// `toNativeUtf8()` allocation length, including the trailing NUL. +void zeroNativeUtf8String(Pointer pointer, String? value) { + if (pointer == nullptr || value == null) return; + + zeroNativeUint8Buffer(pointer.cast(), utf8.encode(value).length + 1); +} + +typedef _FreeStringC = Void Function(Pointer); +typedef _FreeStringDart = void Function(Pointer); + +typedef _FreeBufferC = Void Function(FFIBuffer); +typedef _FreeBufferDart = void Function(FFIBuffer); + +typedef _GetLastErrorC = Pointer Function(); +typedef _GetLastErrorDart = Pointer Function(); + +typedef _VersionC = Pointer Function(); +typedef _VersionDart = Pointer Function(); + +typedef _InitKeysC = Int64 Function( + Pointer seed, Size seedLen, Uint8 isTestnet); +typedef _InitKeysDart = int Function( + Pointer seed, int seedLen, int isTestnet); + +typedef _DisposeKeysC = Void Function(Int64 handle); +typedef _DisposeKeysDart = void Function(int handle); + +typedef _GetDefaultAddressC = Pointer Function(Int64 handle); +typedef _GetDefaultAddressDart = Pointer Function(int handle); + +typedef _DeriveAddressC = Pointer Function(Int64 handle, Uint64 index); +typedef _DeriveAddressDart = Pointer Function(int handle, int index); + +typedef _GetViewingKeyC = Pointer Function(Int64 handle); +typedef _GetViewingKeyDart = Pointer Function(int handle); + +typedef _ValidateAddressC = Uint8 Function( + Pointer address, Uint8 isTestnet); +typedef _ValidateAddressDart = int Function( + Pointer address, int isTestnet); + +typedef _InitSyncEngineC = Int64 Function(Uint8 isTestnet); +typedef _InitSyncEngineDart = int Function(int isTestnet); + +typedef _DisposeSyncEngineC = Void Function(Int64 handle); +typedef _DisposeSyncEngineDart = void Function(int handle); + +typedef _GetSyncHeightC = Uint32 Function(Int64 handle); +typedef _GetSyncHeightDart = int Function(int handle); + +typedef _GetShieldedBalanceC = Uint64 Function(Int64 handle); +typedef _GetShieldedBalanceDart = int Function(int handle); + +typedef _GetUnspentNoteCountC = Size Function(Int64 handle); +typedef _GetUnspentNoteCountDart = int Function(int handle); + +typedef _ResetSyncC = Void Function(Int64 handle); +typedef _ResetSyncDart = void Function(int handle); + +// Trial decryption for detecting incoming shielded transactions +typedef _TryDecryptOutputC = Uint64 Function( + Int64 keyHandle, + Int64 syncHandle, + Pointer cmu, + Pointer epk, + Pointer encCiphertext, + Uint32 height, + Uint32 txIndex, + Uint32 outputIndex, + Uint64 position, +); +typedef _TryDecryptOutputDart = int Function( + int keyHandle, + int syncHandle, + Pointer cmu, + Pointer epk, + Pointer encCiphertext, + int height, + int txIndex, + int outputIndex, + int position, +); + +// Check nullifier (mark notes as spent) +typedef _CheckNullifierC = Uint8 Function( + Int64 syncHandle, Pointer nullifier); +typedef _CheckNullifierDart = int Function( + int syncHandle, Pointer nullifier); + +typedef _SetSyncHeightC = Void Function(Int64 syncHandle, Uint32 height); +typedef _SetSyncHeightDart = void Function(int syncHandle, int height); + +typedef _EstimateFeeC = Uint64 Function( + Size spends, Size outputs, Size tInputs, Size tOutputs); +typedef _EstimateFeeDart = int Function( + int spends, int outputs, int tInputs, int tOutputs); + +typedef _InitProverC = Int32 Function(Pointer paramsDir); +typedef _InitProverDart = int Function(Pointer paramsDir); + +typedef _IsProverInitializedC = Uint8 Function(); +typedef _IsProverInitializedDart = int Function(); + +typedef _DisposeProverC = Void Function(); +typedef _DisposeProverDart = void Function(); + +typedef _HasProvingParamsC = Uint8 Function(Pointer path); +typedef _HasProvingParamsDart = int Function(Pointer path); + +// Advanced transaction building with explicit notes/witnesses +typedef _BuildShieldedTxC = FFIBuffer Function( + Int64 keyHandle, + Pointer notesJson, + Pointer toAddress, + Uint64 amount, + Pointer memo, + Uint64 fee, + Pointer anchorHex, +); +typedef _BuildShieldedTxDart = FFIBuffer Function( + int keyHandle, + Pointer notesJson, + Pointer toAddress, + int amount, + Pointer memo, + int fee, + Pointer anchorHex, +); + +typedef _BuildShieldTxC = FFIBuffer Function( + Int64 keyHandle, + Pointer utxosJson, + Pointer toAddress, + Uint64 amount, + Pointer memo, + Uint64 fee, + Pointer changeAddress, + Uint64 change, +); +typedef _BuildShieldTxDart = FFIBuffer Function( + int keyHandle, + Pointer utxosJson, + Pointer toAddress, + int amount, + Pointer memo, + int fee, + Pointer changeAddress, + int change, +); + +late final _freeString = _nativeLib + .lookupFunction<_FreeStringC, _FreeStringDart>('cw_pivx_free_string'); + +late final _freeBuffer = _nativeLib + .lookupFunction<_FreeBufferC, _FreeBufferDart>('cw_pivx_free_buffer'); + +late final _getLastError = + _nativeLib.lookupFunction<_GetLastErrorC, _GetLastErrorDart>( + 'cw_pivx_get_last_error'); + +late final _version = + _nativeLib.lookupFunction<_VersionC, _VersionDart>('cw_pivx_version'); + +late final _initKeys = + _nativeLib.lookupFunction<_InitKeysC, _InitKeysDart>('cw_pivx_init_keys'); + +late final _disposeKeys = _nativeLib + .lookupFunction<_DisposeKeysC, _DisposeKeysDart>('cw_pivx_dispose_keys'); + +late final _getDefaultAddress = + _nativeLib.lookupFunction<_GetDefaultAddressC, _GetDefaultAddressDart>( + 'cw_pivx_get_default_address'); + +late final _deriveAddress = + _nativeLib.lookupFunction<_DeriveAddressC, _DeriveAddressDart>( + 'cw_pivx_derive_address'); + +late final _getViewingKey = + _nativeLib.lookupFunction<_GetViewingKeyC, _GetViewingKeyDart>( + 'cw_pivx_get_viewing_key'); + +late final _validateAddress = + _nativeLib.lookupFunction<_ValidateAddressC, _ValidateAddressDart>( + 'cw_pivx_validate_address'); + +late final _initSyncEngine = + _nativeLib.lookupFunction<_InitSyncEngineC, _InitSyncEngineDart>( + 'cw_pivx_init_sync_engine'); + +late final _disposeSyncEngine = + _nativeLib.lookupFunction<_DisposeSyncEngineC, _DisposeSyncEngineDart>( + 'cw_pivx_dispose_sync_engine'); + +late final _getSyncHeight = + _nativeLib.lookupFunction<_GetSyncHeightC, _GetSyncHeightDart>( + 'cw_pivx_get_sync_height'); + +late final _getShieldedBalance = + _nativeLib.lookupFunction<_GetShieldedBalanceC, _GetShieldedBalanceDart>( + 'cw_pivx_get_shielded_balance'); + +late final _getUnspentNoteCount = + _nativeLib.lookupFunction<_GetUnspentNoteCountC, _GetUnspentNoteCountDart>( + 'cw_pivx_get_unspent_note_count'); + +late final _resetSync = _nativeLib + .lookupFunction<_ResetSyncC, _ResetSyncDart>('cw_pivx_reset_sync'); + +late final _tryDecryptOutput = + _nativeLib.lookupFunction<_TryDecryptOutputC, _TryDecryptOutputDart>( + 'cw_pivx_try_decrypt_output'); + +late final _checkNullifier = + _nativeLib.lookupFunction<_CheckNullifierC, _CheckNullifierDart>( + 'cw_pivx_check_nullifier'); + +late final _setSyncHeight = + _nativeLib.lookupFunction<_SetSyncHeightC, _SetSyncHeightDart>( + 'cw_pivx_set_sync_height'); + +late final _estimateFee = _nativeLib + .lookupFunction<_EstimateFeeC, _EstimateFeeDart>('cw_pivx_estimate_fee'); + +late final _initProver = _nativeLib + .lookupFunction<_InitProverC, _InitProverDart>('cw_pivx_init_prover'); + +late final _isProverInitialized = + _nativeLib.lookupFunction<_IsProverInitializedC, _IsProverInitializedDart>( + 'cw_pivx_is_prover_initialized'); + +late final _disposeProver = + _nativeLib.lookupFunction<_DisposeProverC, _DisposeProverDart>( + 'cw_pivx_dispose_prover'); + +late final _buildShieldedTx = + _nativeLib.lookupFunction<_BuildShieldedTxC, _BuildShieldedTxDart>( + 'cw_pivx_build_shielded_tx'); +late final _buildShieldTx = + _nativeLib.lookupFunction<_BuildShieldTxC, _BuildShieldTxDart>( + 'cw_pivx_build_shield_tx'); + +late final _hasProvingParams = + _nativeLib.lookupFunction<_HasProvingParamsC, _HasProvingParamsDart>( + 'cw_pivx_has_proving_params'); + +// Local witness-root verification +typedef _VerifyWitnessRootC = Int32 Function( + Pointer witnessHex, + Pointer cmuHex, + Pointer anchorHex, + Uint64 position, +); +typedef _VerifyWitnessRootDart = int Function( + Pointer witnessHex, + Pointer cmuHex, + Pointer anchorHex, + int position, +); + +late final _verifyWitnessRoot = + _nativeLib.lookupFunction<_VerifyWitnessRootC, _VerifyWitnessRootDart>( + 'pivx_sapling_verify_witness_root'); + +typedef _GetSpendableNotesC = Pointer Function(Int64 syncHandle); +typedef _GetSpendableNotesDart = Pointer Function(int syncHandle); + +late final _getSpendableNotes = + _nativeLib.lookupFunction<_GetSpendableNotesC, _GetSpendableNotesDart>( + 'cw_pivx_get_spendable_notes'); + +typedef _GetNoteAtPositionC = Pointer Function( + Int64 syncHandle, Uint64 position); +typedef _GetNoteAtPositionDart = Pointer Function( + int syncHandle, int position); +late final _getNoteAtPosition = + _nativeLib.lookupFunction<_GetNoteAtPositionC, _GetNoteAtPositionDart>( + 'cw_pivx_get_note_at_position'); + +typedef _RestoreNoteC = Int32 Function( + Int64 keyHandle, Int64 syncHandle, Pointer noteJson); +typedef _RestoreNoteDart = int Function( + int keyHandle, int syncHandle, Pointer noteJson); + +late final _restoreNote = _nativeLib + .lookupFunction<_RestoreNoteC, _RestoreNoteDart>('cw_pivx_restore_note'); + +String? getLastError() { + _ensureLoaded(); + if (!_nativeLibLoaded) return _nativeLibError; + + final ptr = _getLastError(); + if (ptr == nullptr) return null; + + final error = ptr.toDartString(); + _freeString(ptr); + return error; +} + +String getVersion() { + _ensureLoaded(); + if (!_nativeLibLoaded) return 'not loaded'; + + final ptr = _version(); + if (ptr == nullptr) return 'unknown'; + + final version = ptr.toDartString(); + _freeString(ptr); + return version; +} + +/// Native-library self-test for release validation: library loads, FFI symbols +/// resolve, version is callable, and native fee estimation matches the fee policy. +SaplingNativeSelfTestResult runSaplingNativeSelfTest() { + _ensureLoaded(); + if (!_nativeLibLoaded) { + final error = _nativeUnavailableMessage(); + _nativeSelfTestError = error; + return SaplingNativeSelfTestResult( + loaded: false, + symbolsReady: false, + feeMatchesPolicy: false, + error: error, + ); + } + + try { + final version = getVersion(); + if (version == 'not loaded' || version == 'unknown') { + final error = 'Native version symbol returned $version'; + _nativeSelfTestError = error; + return SaplingNativeSelfTestResult( + loaded: true, + symbolsReady: false, + feeMatchesPolicy: false, + version: version, + error: error, + ); + } + + final nativeFee = estimateFee(numSpends: 1, numOutputs: 1); + final expectedFee = PivxFeePolicy.saplingFee( + saplingInputs: 1, + saplingOutputs: 1, + ); + if (nativeFee != expectedFee) { + final error = + 'Native fee policy mismatch: native=$nativeFee expected=$expectedFee'; + _nativeSelfTestError = error; + return SaplingNativeSelfTestResult( + loaded: true, + symbolsReady: true, + feeMatchesPolicy: false, + version: version, + error: error, + ); + } + + _nativeSelfTestError = null; + return SaplingNativeSelfTestResult( + loaded: true, + symbolsReady: true, + feeMatchesPolicy: true, + version: version, + ); + } catch (e) { + final error = e.toString(); + _nativeSelfTestError = error; + return SaplingNativeSelfTestResult( + loaded: true, + symbolsReady: false, + feeMatchesPolicy: false, + error: error, + ); + } +} + +bool validateAddress(String address, {bool isTestnet = false}) { + _ensureLoaded(); + if (!_nativeLibLoaded) return false; + + final addressPtr = address.toNativeUtf8(); + try { + return _validateAddress(addressPtr, isTestnet ? 1 : 0) == 1; + } finally { + zeroNativeUtf8String(addressPtr, address); + malloc.free(addressPtr); + } +} + +int estimateFee({ + int numSpends = 0, + int numOutputs = 0, + int numTransparentInputs = 0, + int numTransparentOutputs = 0, +}) { + _ensureLoaded(); + if (!_nativeLibLoaded) throw StateError(_nativeUnavailableMessage()); + return _estimateFee( + numSpends, numOutputs, numTransparentInputs, numTransparentOutputs); +} + +/// Verify a server-supplied witness by recomputing its Sapling Merkle root +/// locally and comparing it to the expected anchor. +/// +/// [witnessHex]: 32 concatenated sibling hashes as hex (2048 hex chars), the +/// same serialization passed to the native transaction builder. +/// [cmuHex]: 32-byte note commitment as hex. +/// [anchorHex]: 32-byte expected anchor (Merkle root) as hex. +/// [position]: Position of the note in the commitment tree. +/// +/// Returns true when the recomputed root equals the anchor, false on a clean +/// mismatch. Throws [StateError] when verification itself fails (native +/// library unavailable, malformed or non-canonical inputs). +bool verifyWitnessRoot({ + required String witnessHex, + required String cmuHex, + required String anchorHex, + required int position, +}) { + _ensureLoaded(); + if (!_nativeLibLoaded) throw StateError(_nativeUnavailableMessage()); + + final witnessPtr = witnessHex.toNativeUtf8(); + final cmuPtr = cmuHex.toNativeUtf8(); + final anchorPtr = anchorHex.toNativeUtf8(); + try { + final result = _verifyWitnessRoot(witnessPtr, cmuPtr, anchorPtr, position); + if (result == 1) return true; + if (result == 0) return false; + throw StateError( + 'Witness root verification error: ${getLastError() ?? 'unknown'}'); + } finally { + zeroNativeUtf8String(witnessPtr, witnessHex); + malloc.free(witnessPtr); + zeroNativeUtf8String(cmuPtr, cmuHex); + malloc.free(cmuPtr); + zeroNativeUtf8String(anchorPtr, anchorHex); + malloc.free(anchorPtr); + } +} + +bool hasProvingParams(String path) { + _ensureLoaded(); + if (!_nativeLibLoaded) return false; + final pathPtr = path.toNativeUtf8(); + try { + return _hasProvingParams(pathPtr) == 1; + } finally { + zeroNativeUtf8String(pathPtr, path); + malloc.free(pathPtr); + } +} + +/// Load the Groth16 proving params (~50 MB) into memory; call once before +/// building transactions. Returns false on failure; see [getLastError]. +bool initProver(String paramsDir) { + _ensureLoaded(); + if (!_nativeLibLoaded) return false; + + final dirPtr = paramsDir.toNativeUtf8(); + try { + return _initProver(dirPtr) == 0; + } finally { + zeroNativeUtf8String(dirPtr, paramsDir); + malloc.free(dirPtr); + } +} + +bool isProverInitialized() { + _ensureLoaded(); + if (!_nativeLibLoaded) return false; + return _isProverInitialized() == 1; +} + +/// Free the prover and release memory (~50MB). +void disposeProver() { + _ensureLoaded(); + if (!_nativeLibLoaded) return; + _disposeProver(); +} + +/// Spendable notes from the sync state (all fields needed to build a tx). +List> getSpendableNotes(int syncHandle) { + _ensureLoaded(); + if (!_nativeLibLoaded) return []; + + final ptr = _getSpendableNotes(syncHandle); + if (ptr == nullptr) { + return []; + } + + try { + final jsonStr = ptr.toDartString(); + final list = jsonDecode(jsonStr) as List; + return list.map((e) => Map.from(e as Map)).toList(); + } finally { + _freeString(ptr); + } +} + +/// The single unspent note at [position] (the one just decrypted), or null. +/// Avoids re-serializing every note via getSpendableNotes on each match. +Map? getNoteAtPosition(int syncHandle, int position) { + _ensureLoaded(); + if (!_nativeLibLoaded) return null; + + final ptr = _getNoteAtPosition(syncHandle, position); + if (ptr == nullptr) return null; + + try { + return Map.from(jsonDecode(ptr.toDartString()) as Map); + } finally { + _freeString(ptr); + } +} + +/// Restore a note from persistent storage (same fields as getSpendableNotes); +/// returns false on failure. +bool restoreNote({ + required int keyHandle, + required int syncHandle, + required Map noteData, +}) { + _ensureLoaded(); + if (!_nativeLibLoaded) return false; + + final jsonStr = jsonEncode(noteData); + final jsonPtr = jsonStr.toNativeUtf8(); + + try { + return _restoreNote(keyHandle, syncHandle, jsonPtr) == 1; + } finally { + zeroNativeUtf8String(jsonPtr, jsonStr); + malloc.free(jsonPtr); + } +} + +/// Build a shielded transaction from explicit notes and witnesses. +/// [notesJson] is a JSON array of SpendableNoteData; [anchorHex] is the 32-byte +/// Merkle root as hex. Returns a Map of tx details or throws on error. +Map buildShieldedTransaction({ + required int keyHandle, + required String notesJson, + required String toAddress, + required int amount, + String? memo, + required int fee, + required String anchorHex, +}) { + _ensureLoaded(); + if (!_nativeLibLoaded) { + throw Exception('Native library not available: $_nativeLibError'); + } + + final notesPtr = notesJson.toNativeUtf8(); + final toPtr = toAddress.toNativeUtf8(); + final memoPtr = memo?.toNativeUtf8() ?? nullptr; + final anchorPtr = anchorHex.toNativeUtf8(); + + try { + final buffer = _buildShieldedTx( + keyHandle, + notesPtr, + toPtr, + amount, + memoPtr, + fee, + anchorPtr, + ); + + if (buffer.data == nullptr || buffer.len == 0) { + throw Exception(getLastError() ?? 'Failed to build transaction'); + } + + final resultStr = buffer.data.cast().toDartString(length: buffer.len); + _freeBuffer(buffer); + + return Map.from( + (const JsonDecoder().convert(resultStr)) as Map, + ); + } finally { + zeroNativeUtf8String(notesPtr, notesJson); + malloc.free(notesPtr); + zeroNativeUtf8String(toPtr, toAddress); + malloc.free(toPtr); + if (memoPtr != nullptr) { + zeroNativeUtf8String(memoPtr, memo); + malloc.free(memoPtr); + } + zeroNativeUtf8String(anchorPtr, anchorHex); + malloc.free(anchorPtr); + } +} + +/// Build a transparent-to-shielded (t-to-z, shield) transaction. +/// [utxosJson] is a JSON array of UTXOs (txid, vout, value, script_pubkey hex, +/// private_key 32-byte hex); [changeAddress]/[change] is an optional transparent +/// change output. Amounts must balance exactly: sum(utxos) = amount + change + fee. +Map buildShieldTransaction({ + required int keyHandle, + required String utxosJson, + required String toAddress, + required int amount, + String? memo, + required int fee, + String? changeAddress, + int change = 0, +}) { + _ensureLoaded(); + if (!_nativeLibLoaded) { + throw Exception('Native library not available: $_nativeLibError'); + } + + final utxosPtr = utxosJson.toNativeUtf8(); + final toPtr = toAddress.toNativeUtf8(); + final memoPtr = memo?.toNativeUtf8() ?? nullptr; + final changePtr = changeAddress?.toNativeUtf8() ?? nullptr; + + try { + final buffer = _buildShieldTx( + keyHandle, + utxosPtr, + toPtr, + amount, + memoPtr, + fee, + changePtr, + change, + ); + + if (buffer.data == nullptr || buffer.len == 0) { + throw Exception(getLastError() ?? 'Failed to build shield transaction'); + } + + final resultStr = buffer.data.cast().toDartString(length: buffer.len); + _freeBuffer(buffer); + + return Map.from( + (const JsonDecoder().convert(resultStr)) as Map, + ); + } finally { + zeroNativeUtf8String(utxosPtr, utxosJson); + malloc.free(utxosPtr); + zeroNativeUtf8String(toPtr, toAddress); + malloc.free(toPtr); + if (memoPtr != nullptr) { + zeroNativeUtf8String(memoPtr, memo); + malloc.free(memoPtr); + } + if (changePtr != nullptr) { + zeroNativeUtf8String(changePtr, changeAddress); + malloc.free(changePtr); + } + } +} + +/// PIVX Sapling key manager handle; manages shielded keys derived from a seed. +class SaplingKeys { + final int _handle; + bool _disposed = false; + + SaplingKeys._(this._handle); + + static SaplingKeys fromSeed(Uint8List seed, {bool isTestnet = false}) { + _ensureLoaded(); + if (!_nativeLibLoaded) { + throw Exception('Native library not available: $_nativeLibError'); + } + + final seedPtr = malloc(seed.length); + try { + seedPtr.asTypedList(seed.length).setAll(0, seed); + + final handle = _initKeys(seedPtr, seed.length, isTestnet ? 1 : 0); + if (handle < 0) { + throw Exception(getLastError() ?? 'Failed to initialize keys'); + } + + return SaplingKeys._(handle); + } finally { + zeroNativeUint8Buffer(seedPtr, seed.length); + malloc.free(seedPtr); + } + } + + String getDefaultAddress() { + _checkDisposed(); + + final ptr = _getDefaultAddress(_handle); + if (ptr == nullptr) { + throw Exception(getLastError() ?? 'Failed to get address'); + } + + final address = ptr.toDartString(); + _freeString(ptr); + return address; + } + + String deriveAddress(int index) { + _checkDisposed(); + + final ptr = _deriveAddress(_handle, index); + if (ptr == nullptr) { + throw Exception(getLastError() ?? 'Failed to derive address'); + } + + final address = ptr.toDartString(); + _freeString(ptr); + return address; + } + + /// Full viewing key (for watch-only wallets). + String getViewingKey() { + _checkDisposed(); + + final ptr = _getViewingKey(_handle); + if (ptr == nullptr) { + throw Exception(getLastError() ?? 'Failed to get viewing key'); + } + + final key = ptr.toDartString(); + _freeString(ptr); + return key; + } + + void dispose() { + if (!_disposed) { + _disposeKeys(_handle); + _disposed = true; + } + } + + void _checkDisposed() { + if (_disposed) { + throw StateError('SaplingKeys has been disposed'); + } + } + + int get handle { + _checkDisposed(); + return _handle; + } +} + +/// PIVX Sapling sync engine handle; manages block sync and note tracking. +class SaplingSyncEngine { + final int _handle; + bool _disposed = false; + + SaplingSyncEngine._(this._handle); + + int get handle => _handle; + + factory SaplingSyncEngine({bool isTestnet = false}) { + _ensureLoaded(); + if (!_nativeLibLoaded) { + throw Exception(_nativeUnavailableMessage()); + } + final handle = _initSyncEngine(isTestnet ? 1 : 0); + if (handle < 0) { + throw Exception(getLastError() ?? 'Failed to initialize sync engine'); + } + return SaplingSyncEngine._(handle); + } + + int get syncHeight { + _checkDisposed(); + return _getSyncHeight(_handle); + } + + /// In satoshis. + int get shieldedBalance { + _checkDisposed(); + return _getShieldedBalance(_handle); + } + + int get unspentNoteCount { + _checkDisposed(); + return _getUnspentNoteCount(_handle); + } + + /// For rescan. + void reset() { + _checkDisposed(); + _resetSync(_handle); + } + + void setSyncHeight(int height) { + _checkDisposed(); + _setSyncHeight(_handle, height); + } + + /// Trial-decrypt a Sapling output with the wallet's incoming viewing key. + /// Returns the note value in zatoshis on success, 0 otherwise. + int tryDecryptOutput({ + required SaplingKeys keys, + required Uint8List cmu, + required Uint8List epk, + required Uint8List encCiphertext, + required int height, + required int txIndex, + required int outputIndex, + required int position, + }) { + _checkDisposed(); + + if (cmu.length != 32) throw ArgumentError('cmu must be 32 bytes'); + if (epk.length != 32) throw ArgumentError('epk must be 32 bytes'); + if (encCiphertext.length != 580) + throw ArgumentError('encCiphertext must be 580 bytes'); + + final cmuPtr = malloc(32); + final epkPtr = malloc(32); + final encPtr = malloc(580); + + try { + cmuPtr.asTypedList(32).setAll(0, cmu); + epkPtr.asTypedList(32).setAll(0, epk); + encPtr.asTypedList(580).setAll(0, encCiphertext); + + final result = _tryDecryptOutput( + keys.handle, + _handle, + cmuPtr, + epkPtr, + encPtr, + height, + txIndex, + outputIndex, + position, + ); + + return result; + } finally { + zeroNativeUint8Buffer(cmuPtr, 32); + zeroNativeUint8Buffer(epkPtr, 32); + zeroNativeUint8Buffer(encPtr, 580); + malloc.free(cmuPtr); + malloc.free(epkPtr); + malloc.free(encPtr); + } + } + + /// Mark our note spent if [nullifier] matches; returns true if one was marked. + bool checkNullifier(Uint8List nullifier) { + _checkDisposed(); + + if (nullifier.length != 32) + throw ArgumentError('nullifier must be 32 bytes'); + + final nullifierPtr = malloc(32); + try { + nullifierPtr.asTypedList(32).setAll(0, nullifier); + return _checkNullifier(_handle, nullifierPtr) == 1; + } finally { + zeroNativeUint8Buffer(nullifierPtr, 32); + malloc.free(nullifierPtr); + } + } + + void dispose() { + if (!_disposed) { + _disposeSyncEngine(_handle); + _disposed = true; + } + } + + void _checkDisposed() { + if (_disposed) { + throw StateError('SaplingSyncEngine has been disposed'); + } + } +} diff --git a/cw_pivx/lib/src/sapling/sapling_key_manager.dart b/cw_pivx/lib/src/sapling/sapling_key_manager.dart new file mode 100644 index 0000000000..a73f1d2424 --- /dev/null +++ b/cw_pivx/lib/src/sapling/sapling_key_manager.dart @@ -0,0 +1,201 @@ +/// Sapling key derivation from a BIP39 seed per ZIP-32 +/// (https://zips.z.cash/zip-0032). Key hierarchy for PIVX Sapling: +/// ``` +/// seed (64 bytes from BIP39) +/// └── master extended spending key (m_sapling) +/// └── purpose = 32' (hardened, Sapling) +/// └── coin_type = 119' (hardened, PIVX) +/// └── account = n' (hardened) +/// ├── Extended Spending Key (extsk) +/// │ └── Used to spend notes +/// └── Extended Full Viewing Key (extfvk) +/// ├── Used to scan for incoming notes +/// └── Diversified payment addresses +/// ``` +library; + +import 'dart:typed_data'; + +import 'sapling_constants.dart'; + +/// Sapling extended spending key. Contains: +/// - ask (256 bits): The spend authorizing key +/// - nsk (256 bits): The nullifier private key +/// - ovk (256 bits): The outgoing viewing key +/// - dk (256 bits): The diversifier key +/// - chain_code (256 bits): The chain code for derivation +/// +/// This key can derive child keys and sign transactions. +class SaplingExtendedSpendingKey { + SaplingExtendedSpendingKey({ + required this.raw, + required this.encoded, + required this.isTestnet, + }); + + final Uint8List raw; + + /// Bech32-encoded key; format [HRP]1[data], HRP 'p-secret-extended-key-main'/'-test'. + final String encoded; + + final bool isTestnet; + + String get hrp => isTestnet + ? PivxSaplingNetwork.testnetExtendedSpendingKeyHrp + : PivxSaplingNetwork.mainnetExtendedSpendingKeyHrp; +} + +/// Sapling extended full viewing key. Contains: +/// - ak (256 bits): The spend validating key (derived from ask) +/// - nk (256 bits): The nullifier deriving key (derived from nsk) +/// - ovk (256 bits): The outgoing viewing key +/// - dk (256 bits): The diversifier key +/// - chain_code (256 bits): The chain code for derivation +/// +/// This key can: +/// - Derive payment addresses +/// - Scan for incoming notes (trial decryption) +/// - Derive nullifiers for spent detection +/// - View outgoing transaction details +/// +/// It CANNOT sign transactions (that requires the spending key). +class SaplingExtendedFullViewingKey { + SaplingExtendedFullViewingKey({ + required this.raw, + required this.encoded, + required this.isTestnet, + }); + + final Uint8List raw; + + /// Bech32-encoded key; format [HRP]1[data], HRP 'pviews'/'pviewtestsapling'. + final String encoded; + + final bool isTestnet; + + String get hrp => isTestnet + ? PivxSaplingNetwork.testnetFullViewingKeyHrp + : PivxSaplingNetwork.mainnetFullViewingKeyHrp; +} + +/// Sapling incoming viewing key (ivk), derived from ak and nk: +/// ivk = CRH^ivk(ak, nk). +/// Decrypts incoming notes only (trial decryption); cannot derive nullifiers +/// or view outgoing transactions. +class SaplingIncomingViewingKey { + SaplingIncomingViewingKey({ + required this.raw, + required this.encoded, + required this.isTestnet, + }); + + /// The raw key bytes (32 bytes). + final Uint8List raw; + + final String encoded; + + final bool isTestnet; +} + +/// Sapling diversifier. Yields multiple unlinkable addresses from one viewing +/// key; 11 bytes, and not all 11-byte values are valid diversifiers. +class SaplingDiversifier { + SaplingDiversifier({ + required this.bytes, + required this.index, + }); + + final Uint8List bytes; + + /// The diversifier index used to derive this diversifier. + final Uint8List index; + + /// True for the default diversifier (index 0). + bool get isDefault { + for (final b in index) { + if (b != 0) return false; + } + return true; + } +} + +/// Sapling payment address. Consists of: +/// - diversifier d (11 bytes): unique per address +/// - pk_d (32 bytes): diversified transmission key +/// Encoded as [HRP]1[Bech32(d || pk_d)]. +class SaplingPaymentAddress { + SaplingPaymentAddress({ + required this.diversifier, + required this.pkD, + required this.encoded, + required this.isTestnet, + }); + + final Uint8List diversifier; + + final Uint8List pkD; + + /// Bech32-encoded address; ps1... (mainnet) or ptestsapling1... (testnet). + final String encoded; + + final bool isTestnet; + + String get hrp => isTestnet + ? PivxSaplingNetwork.testnetPaymentAddressHrp + : PivxSaplingNetwork.mainnetPaymentAddressHrp; + + /// Raw address bytes (43 bytes). + Uint8List get raw { + final bytes = Uint8List(43); + bytes.setAll(0, diversifier); + bytes.setAll(11, pkD); + return bytes; + } +} + +/// Manages Sapling key derivation and diversified address generation: keys from +/// a BIP39 seed, multiple accounts, transaction signing, and note scanning. +abstract class SaplingKeyManager { + SaplingKeyManager({ + required this.seed, + required this.isTestnet, + this.accountIndex = 0, + }); + + /// The BIP39 seed (64 bytes). + final Uint8List seed; + + final bool isTestnet; + + /// The account index (used in key derivation path). + final int accountIndex; + + int get coinType => isTestnet + ? PivxSaplingNetwork.testnetCoinType + : PivxSaplingNetwork.mainnetCoinType; + + /// Derive master and account keys from the seed. Must be called first. + Future initialize(); + + Future getExtendedSpendingKey(); + + Future getExtendedFullViewingKey(); + + Future getIncomingViewingKey(); + + /// Default payment address (diversifier index 0). + Future getDefaultAddress(); + + /// Next unused address: advances the diversifier index to the next valid one. + Future getNextAddress(); + + /// Address at [diversifierIndex] (11-byte index); null if that index is invalid. + Future getAddressAtIndex(Uint8List diversifierIndex); + + /// Whether [address] belongs to this wallet. + Future isOwnAddress(String address); + + Uint8List get currentDiversifierIndex; + + void dispose(); +} diff --git a/cw_pivx/lib/src/sapling/sapling_note.dart b/cw_pivx/lib/src/sapling/sapling_note.dart new file mode 100644 index 0000000000..4d5f32a231 --- /dev/null +++ b/cw_pivx/lib/src/sapling/sapling_note.dart @@ -0,0 +1,187 @@ +/// Sapling note data model: a unit of value in the shielded pool, decryptable +/// and spendable only by the holder of the spending key. +library; + +import 'dart:typed_data'; + +import 'package:hive/hive.dart'; + +part 'sapling_note.g.dart'; + +/// A Sapling note (shielded value). The note commitment is: +/// NoteCommitment = PedersenHash("Zcash_PH", [g_d^ivk | pk_d | v | rcm]). +@HiveType(typeId: 150) +class SaplingNote { + SaplingNote({ + required this.diversifier, + required this.pkD, + required this.value, + required this.rcm, + required this.rseed, + this.memo, + }); + + /// Diversifier (11 bytes); derives the transmission key g_d. + @HiveField(0) + final Uint8List diversifier; + + /// Diversified transmission key pk_d (32 bytes). + @HiveField(1) + final Uint8List pkD; + + /// The value of the note in zatoshis. + @HiveField(2) + final int value; + + /// Commitment randomness rcm (32 bytes); blinds the note commitment. + @HiveField(3) + final Uint8List rcm; + + /// Note randomness seed (32 bytes); derives v2-note randomness. + @HiveField(4) + final Uint8List rseed; + + /// Optional memo field (512 bytes, UTF-8 encoded). + @HiveField(5) + final String? memo; + + Uint8List toBytes() { + final writer = BytesBuilder(); + writer.add(diversifier); + writer.add(pkD); + writer.add(_intToBytes(value, 8)); + writer.add(rcm); + writer.add(rseed); + return writer.toBytes(); + } + + static SaplingNote fromBytes(Uint8List bytes) { + if (bytes.length < 115) { + throw ArgumentError('Invalid note bytes length'); + } + var offset = 0; + final diversifier = bytes.sublist(offset, offset + 11); + offset += 11; + final pkD = bytes.sublist(offset, offset + 32); + offset += 32; + final value = _bytesToInt(bytes.sublist(offset, offset + 8)); + offset += 8; + final rcm = bytes.sublist(offset, offset + 32); + offset += 32; + final rseed = bytes.sublist(offset, offset + 32); + return SaplingNote( + diversifier: diversifier, + pkD: pkD, + value: value, + rcm: rcm, + rseed: rseed, + ); + } + + static Uint8List _intToBytes(int value, int length) { + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + bytes[i] = (value >> (i * 8)) & 0xFF; + } + return bytes; + } + + static int _bytesToInt(Uint8List bytes) { + var value = 0; + for (var i = 0; i < bytes.length; i++) { + value |= bytes[i] << (i * 8); + } + return value; + } +} + +/// A spendable Sapling note plus its witness (Merkle path to the tree root) and +/// nullifier. The witness is a serialized incremental witness that tracks the +/// note's tree position as blocks are added. +@HiveType(typeId: 151) +class SpendableNote { + SpendableNote({ + required this.note, + required this.witness, + required this.nullifier, + required this.txid, + required this.outputIndex, + required this.blockHeight, + required this.isSpent, + this.spendingTxid, + }); + + @HiveField(0) + final SaplingNote note; + + /// Incremental witness (Merkle path), serialized as hex. + @HiveField(1) + String witness; + + /// Nullifier (32-byte hex); derived from note + spending key + witness position. + @HiveField(2) + final String nullifier; + + /// The transaction ID that created this note. + @HiveField(3) + final String txid; + + /// The output index within the transaction. + @HiveField(4) + final int outputIndex; + + /// The block height where this note was created. + @HiveField(5) + final int blockHeight; + + /// Whether this note has been spent. + @HiveField(6) + bool isSpent; + + /// The transaction ID that spent this note (if spent). + @HiveField(7) + String? spendingTxid; + + /// The value of the note in zatoshis. + int get value => note.value; + + /// The value of the note in PIVX. + double get valuePivx => value / 100000000.0; +} + +class SaplingSyncStatus { + SaplingSyncStatus({ + required this.lastSyncedBlock, + required this.currentBlock, + required this.progress, + required this.isSyncing, + this.error, + }); + + /// The last block that was fully synced. + final int lastSyncedBlock; + + /// The current chain tip. + final int currentBlock; + + /// Sync progress as a percentage (0.0 to 1.0). + final double progress; + + final bool isSyncing; + + final String? error; + + factory SaplingSyncStatus.notStarted() => SaplingSyncStatus( + lastSyncedBlock: 0, + currentBlock: 0, + progress: 0.0, + isSyncing: false, + ); + + factory SaplingSyncStatus.complete(int block) => SaplingSyncStatus( + lastSyncedBlock: block, + currentBlock: block, + progress: 1.0, + isSyncing: false, + ); +} diff --git a/cw_pivx/lib/src/sapling/sapling_note_storage.dart b/cw_pivx/lib/src/sapling/sapling_note_storage.dart new file mode 100644 index 0000000000..6fc32f32e0 --- /dev/null +++ b/cw_pivx/lib/src/sapling/sapling_note_storage.dart @@ -0,0 +1,1015 @@ +/// Persistent storage for Sapling notes discovered during sync (JSON file). + +import 'dart:convert'; +import 'dart:io'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:synchronized/synchronized.dart'; + +/// Provisional PIVX shielded confirmation policy used by wallet-side balance +/// separation until release owner/canonical Core policy is confirmed. +class PivxShieldedConfirmationPolicy { + static const int receiveConfirmations = 6; + static const int spendConfirmations = 6; +} + +/// Count-only shielded spend-eligibility diagnostics. Intentionally omits +/// values, txids, commitments, nullifiers, and addresses so logs can explain +/// selection failures without exposing wallet metadata. +class PivxShieldedSpendEligibilitySummary { + final int chainHeight; + final int minConfirmations; + final int totalUnspent; + final int spendable; + final int pendingConfirmation; + final int pendingSpend; + final int missingSpendingData; + + const PivxShieldedSpendEligibilitySummary({ + required this.chainHeight, + required this.minConfirmations, + required this.totalUnspent, + required this.spendable, + required this.pendingConfirmation, + required this.pendingSpend, + required this.missingSpendingData, + }); + + String get sanitizedLogLine => + 'chain_height=$chainHeight min_confirmations=$minConfirmations ' + 'total_unspent=$totalUnspent spendable=$spendable ' + 'pending_confirmations=$pendingConfirmation ' + 'pending_spend=$pendingSpend missing_spending_data=$missingSpendingData'; +} + +class StoredSaplingNote { + /// Unique identifier (txid:index). + final String id; + + /// The value in zatoshis. + final int value; + + /// Block height where this note was created. + final int height; + + /// Transaction ID that created this note. + final String txid; + + final int outputIndex; + + final int treePosition; + + /// Note commitment (cmu) as hex. + final String cmu; + + /// Nullifier as hex (computed when we have spending key). + final String? nullifier; + + bool isSpent; + + /// Whether this note is reserved by a locally broadcast shielded spend that + /// has not been observed in a mined Sapling spend yet. + bool isPendingSpend; + + /// Whether a server-reported spend was observed for this note without a + /// matching locally broadcast transaction. Quarantined notes are excluded + /// from spendable balance but the marker is reversible by rescan, so a + /// malicious server cannot permanently freeze funds with fabricated spends. + bool isProvisionallySpent; + + /// Transaction ID that spent this note (if spent). + String? spendingTxid; + + /// Block height where this note's nullifier was mined as spent. + int? spendingHeight; + + /// Transaction ID that is expected to spend this note, if pending. + String? pendingSpendingTxid; + + /// Timestamp when the outgoing spend reservation was recorded. + DateTime? pendingSpendAt; + + final DateTime discoveredAt; + + /// Unix epoch of the block that mined this note. History dates off this, not + /// discoveredAt (scan time), so an import shows real times. Mutable so a + /// rescan keeps it; null for legacy notes stored before it was captured. + int? blockTime; + + /// Decrypted memo. Mutable so a rescan (which loses the memo because native + /// restore drops it) can keep the previously stored value. + String? memo; + + // Cryptographic data needed to restore note to native engine + /// Random seed (rseed) as hex, 32 bytes + final String? rseed; + + /// Diversifier as hex, 11 bytes + final String? diversifier; + + /// Diversified transmission key (pk_d) as hex, 32 bytes + final String? pkD; + + /// Recipient address as hex + final String? address; + + /// Transaction index within the block + final int? txIndex; + + StoredSaplingNote({ + required this.id, + required this.value, + required this.height, + required this.txid, + required this.outputIndex, + required this.treePosition, + required this.cmu, + this.nullifier, + this.isSpent = false, + this.isPendingSpend = false, + this.isProvisionallySpent = false, + this.spendingTxid, + this.spendingHeight, + this.pendingSpendingTxid, + this.pendingSpendAt, + DateTime? discoveredAt, + this.blockTime, + this.memo, + this.rseed, + this.diversifier, + this.pkD, + this.address, + this.txIndex, + }) : discoveredAt = discoveredAt ?? DateTime.now(); + + factory StoredSaplingNote.fromJson(Map json) { + return StoredSaplingNote( + id: json['id'] as String, + value: json['value'] as int, + height: json['height'] as int, + txid: json['txid'] as String, + outputIndex: json['outputIndex'] as int, + treePosition: json['treePosition'] as int, + cmu: json['cmu'] as String, + nullifier: json['nullifier'] as String?, + isSpent: json['isSpent'] as bool? ?? false, + isPendingSpend: json['isPendingSpend'] as bool? ?? false, + isProvisionallySpent: json['isProvisionallySpent'] as bool? ?? false, + spendingTxid: json['spendingTxid'] as String?, + spendingHeight: json['spendingHeight'] as int?, + pendingSpendingTxid: json['pendingSpendingTxid'] as String?, + pendingSpendAt: json['pendingSpendAt'] != null + ? DateTime.parse(json['pendingSpendAt'] as String) + : null, + discoveredAt: json['discoveredAt'] != null + ? DateTime.parse(json['discoveredAt'] as String) + : null, + blockTime: json['blockTime'] as int? ?? json['block_time'] as int?, + memo: json['memo'] as String?, + rseed: json['rseed'] as String?, + diversifier: json['diversifier'] as String?, + pkD: json['pk_d'] as String? ?? json['pkD'] as String?, + address: json['address'] as String?, + txIndex: json['tx_index'] as int? ?? json['txIndex'] as int?, + ); + } + + Map toJson() { + return { + 'id': id, + 'value': value, + 'height': height, + 'txid': txid, + 'outputIndex': outputIndex, + 'treePosition': treePosition, + 'cmu': cmu, + 'nullifier': nullifier, + 'isSpent': isSpent, + 'isPendingSpend': isPendingSpend, + 'isProvisionallySpent': isProvisionallySpent, + 'spendingTxid': spendingTxid, + 'spendingHeight': spendingHeight, + 'pendingSpendingTxid': pendingSpendingTxid, + 'pendingSpendAt': pendingSpendAt?.toIso8601String(), + 'discoveredAt': discoveredAt.toIso8601String(), + 'blockTime': blockTime, + 'memo': memo, + 'rseed': rseed, + 'diversifier': diversifier, + 'pk_d': pkD, + 'address': address, + 'tx_index': txIndex, + }; + } + + /// JSON with the exact keys expected by the native cw_pivx_restore_note. + Map toNativeRestoreJson() { + // The address field should be diversifier + pk_d concatenated (43 bytes as hex = 86 chars) + final addressHex = address ?? ((diversifier ?? '') + (pkD ?? '')); + + return { + 'value': value, + 'position': treePosition, + 'height': height, + 'tx_index': txIndex ?? 0, + 'output_index': outputIndex, + 'nullifier': nullifier ?? '', + 'rseed': rseed ?? '', + 'address': addressHex, + 'diversifier': diversifier ?? '', + 'pk_d': pkD ?? '', + 'cmu': cmu, + }; + } + + /// Check if this note has all the cryptographic data needed for spending. + bool get hasSpendingData => + rseed != null && diversifier != null && pkD != null && nullifier != null; + + /// Confirmation count at [chainHeight]. The block containing the note counts + /// as the first confirmation. + int confirmationsAt(int chainHeight) { + if (height <= 0 || chainHeight < height) return 0; + return chainHeight - height + 1; + } + + bool isConfirmedAt(int chainHeight, int minConfirmations) => + confirmationsAt(chainHeight) >= minConfirmations; + + /// The value in PIVX. + double get valuePivx => value / 100000000.0; +} + +class StoredShieldedAddress { + final int diversifierIndex; + + /// The encoded address (ps1...). + final String address; + + String? label; + + /// Default address (index 0). + final bool isDefault; + + final DateTime createdAt; + + StoredShieldedAddress({ + required this.diversifierIndex, + required this.address, + this.label, + this.isDefault = false, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); + + factory StoredShieldedAddress.fromJson(Map json) { + return StoredShieldedAddress( + diversifierIndex: json['diversifierIndex'] as int, + address: json['address'] as String, + label: json['label'] as String?, + isDefault: json['isDefault'] as bool? ?? false, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + ); + } + + Map toJson() { + return { + 'diversifierIndex': diversifierIndex, + 'address': address, + 'label': label, + 'isDefault': isDefault, + 'createdAt': createdAt.toIso8601String(), + }; + } +} + +class SaplingNoteStorage { + final String walletId; + final bool isTestnet; + final EncryptionFileUtils? encryptionFileUtils; + final String? password; + final bool allowUnencryptedStorage; + + List _notes = []; + List _addresses = []; + int _lastSyncedHeight = 0; + int _nextTreePosition = 0; + bool _hasPersistedTreePosition = false; + Map _scannedBlockHashes = {}; + int _nextDiversifierIndex = 1; // 0 is the default address + bool _isLoaded = false; + final Lock _lock = Lock(); + + // height of the last full sidecar write. completeSyncRange only rewrites the + // (large, encrypted) file once we've scanned this many blocks past it, instead + // of per block/range. a restore did ~28k inline writes. notes are still saved + // as they're found, so a crash only re-scans the blocks since the checkpoint. + int _lastSavedHeight = 0; + static const int _checkpointEveryBlocks = 10000; + + SaplingNoteStorage({ + required this.walletId, + this.isTestnet = false, + this.encryptionFileUtils, + this.password, + this.allowUnencryptedStorage = false, + }); + + List get notes => List.unmodifiable(_notes); + + List get addresses => List.unmodifiable(_addresses); + + int get nextDiversifierIndex => _nextDiversifierIndex; + + /// Get unspent notes. Quarantined (provisionally spent) notes are excluded + /// so an unverified server-reported spend can never inflate spendable funds. + List get unspentNotes => _notes + .where((n) => !n.isSpent && !n.isPendingSpend && !n.isProvisionallySpent) + .toList(); + + /// Nullifiers of notes quarantined by server-reported spends that did not + /// match a locally broadcast transaction. Lets the wallet layer surface a + /// node-integrity warning. Reset by [clear] (rescan). + List get quarantinedNullifiers => _notes + .where((n) => n.isProvisionallySpent && !n.isSpent) + .map((n) => n.nullifier) + .whereType() + .toList(); + + /// Get notes that can be restored into the native spender and selected. + List get spendableNotes => + unspentNotes.where((n) => n.hasSpendingData).toList(); + + List confirmedNotesAt({ + required int chainHeight, + int minConfirmations = PivxShieldedConfirmationPolicy.receiveConfirmations, + bool requireSpendingData = false, + }) { + return unspentNotes.where((note) { + if (requireSpendingData && !note.hasSpendingData) return false; + return note.isConfirmedAt(chainHeight, minConfirmations); + }).toList(); + } + + List pendingReceivedNotesAt({ + required int chainHeight, + int minConfirmations = PivxShieldedConfirmationPolicy.receiveConfirmations, + }) { + return unspentNotes + .where((note) => !note.isConfirmedAt(chainHeight, minConfirmations)) + .toList(); + } + + List spendableNotesAt({ + required int chainHeight, + int minConfirmations = PivxShieldedConfirmationPolicy.spendConfirmations, + }) { + return confirmedNotesAt( + chainHeight: chainHeight, + minConfirmations: minConfirmations, + requireSpendingData: true, + ); + } + + /// Get notes reserved by an outgoing transaction awaiting confirmation. + List get pendingSpentNotes => + _notes.where((n) => !n.isSpent && n.isPendingSpend).toList(); + + /// Total unreserved observed balance; use getBalanceSafe() for thread safety. + int get balance => unspentNotes.fold(0, (sum, n) => sum + n.value); + + /// Total balance with enough local data to spend. + int get spendableBalance => + spendableNotes.fold(0, (sum, n) => sum + n.value); + + int confirmedBalanceAt({ + required int chainHeight, + int minConfirmations = PivxShieldedConfirmationPolicy.receiveConfirmations, + bool requireSpendingData = true, + }) { + return confirmedNotesAt( + chainHeight: chainHeight, + minConfirmations: minConfirmations, + requireSpendingData: requireSpendingData, + ).fold(0, (sum, note) => sum + note.value); + } + + int pendingReceivedBalanceAt({ + required int chainHeight, + int minConfirmations = PivxShieldedConfirmationPolicy.receiveConfirmations, + }) { + return pendingReceivedNotesAt( + chainHeight: chainHeight, + minConfirmations: minConfirmations, + ).fold(0, (sum, note) => sum + note.value); + } + + int spendableBalanceAt({ + required int chainHeight, + int minConfirmations = PivxShieldedConfirmationPolicy.spendConfirmations, + }) { + return spendableNotesAt( + chainHeight: chainHeight, + minConfirmations: minConfirmations, + ).fold(0, (sum, note) => sum + note.value); + } + + PivxShieldedSpendEligibilitySummary spendEligibilitySummaryAt({ + required int chainHeight, + int minConfirmations = PivxShieldedConfirmationPolicy.spendConfirmations, + }) { + var pendingConfirmation = 0; + var pendingSpend = 0; + var missingSpendingData = 0; + var spendable = 0; + + final unspent = _notes.where((note) => !note.isSpent).toList(); + for (final note in unspent) { + if (note.isPendingSpend || note.isProvisionallySpent) { + pendingSpend++; + continue; + } + if (!note.hasSpendingData) { + missingSpendingData++; + continue; + } + if (!note.isConfirmedAt(chainHeight, minConfirmations)) { + pendingConfirmation++; + continue; + } + spendable++; + } + + return PivxShieldedSpendEligibilitySummary( + chainHeight: chainHeight, + minConfirmations: minConfirmations, + totalUnspent: unspent.length, + spendable: spendable, + pendingConfirmation: pendingConfirmation, + pendingSpend: pendingSpend, + missingSpendingData: missingSpendingData, + ); + } + + /// Get locally reserved outgoing value. + int get pendingOutgoingBalance => + pendingSpentNotes.fold(0, (sum, n) => sum + n.value); + + /// Total balance (thread-safe). + Future getBalanceSafe() async { + return await _lock.synchronized(() { + return balance; + }); + } + + int get lastSyncedHeight => _lastSyncedHeight; + + /// Get the next canonical global Sapling commitment tree position. + int get nextTreePosition => _nextTreePosition; + + /// Whether the global tree cursor came from current encrypted storage. + /// + /// Older sidecars did not persist a global cursor, so loading + /// max(owned-note-position)+1 is only a legacy hint. It must not be trusted + /// for resumed post-activation scanning unless the server returns explicit + /// global output positions. + bool get hasPersistedTreePosition => _hasPersistedTreePosition; + + /// Block hashes recorded for scanned Sapling heights. + Map get scannedBlockHashes => + Map.unmodifiable(_scannedBlockHashes); + + Future get _storagePath async { + final dir = await getApplicationDocumentsDirectory(); + final network = isTestnet ? 'testnet' : 'mainnet'; + return '${dir.path}/pivx_sapling_${walletId}_$network.json.enc'; + } + + /// Legacy plaintext path used before PIVX Sapling sidecar encryption. + Future get _legacyPlaintextStoragePath async { + final dir = await getApplicationDocumentsDirectory(); + final network = isTestnet ? 'testnet' : 'mainnet'; + return '${dir.path}/pivx_sapling_${walletId}_$network.json'; + } + + /// Load notes from storage (thread-safe). + Future load() async { + if (_isLoaded) return; + await _lock.synchronized(() async { + await _loadUnlocked(); + }); + } + + /// Internal load method (must be called within lock). + Future _loadUnlocked() async { + if (_isLoaded) return; + + try { + _assertEncryptedStorageAvailable(); + + final encryptedPath = await _storagePath; + final encryptedFile = File(encryptedPath); + final legacyPath = await _legacyPlaintextStoragePath; + final legacyFile = File(legacyPath); + + if (await encryptedFile.exists()) { + final contents = allowUnencryptedStorage + ? await encryptedFile.readAsString() + : await encryptionFileUtils! + .read(path: encryptedPath, password: password!); + final data = jsonDecode(contents) as Map; + _loadFromJson(data); + } else if (await legacyFile.exists()) { + if (allowUnencryptedStorage) { + final contents = await legacyFile.readAsString(); + final data = jsonDecode(contents) as Map; + _loadFromJson(data); + _isLoaded = true; + return; + } + + final contents = await legacyFile.readAsString(); + final data = jsonDecode(contents) as Map; + _loadFromJson(data); + + await _save(); + await legacyFile.delete(); + } + + _isLoaded = true; + } catch (e) { + printV('[PIVX Sapling Storage] Failed to load encrypted sidecar'); + _notes = []; + _addresses = []; + _lastSyncedHeight = 0; + _nextTreePosition = 0; + _hasPersistedTreePosition = false; + _scannedBlockHashes = {}; + _nextDiversifierIndex = 1; + _isLoaded = false; + rethrow; + } + } + + void _assertEncryptedStorageAvailable() { + if (allowUnencryptedStorage) return; + if (encryptionFileUtils == null || password == null) { + throw StateError( + 'PIVX Sapling sidecar storage requires wallet encryption'); + } + } + + void _loadFromJson(Map data) { + _lastSyncedHeight = data['lastSyncedHeight'] as int? ?? 0; + _nextDiversifierIndex = data['nextDiversifierIndex'] as int? ?? 1; + _notes = (data['notes'] as List?) + ?.map((e) => StoredSaplingNote.fromJson(e as Map)) + .toList() ?? + []; + _addresses = (data['addresses'] as List?) + ?.map((e) => + StoredShieldedAddress.fromJson(e as Map)) + .toList() ?? + []; + + final fallbackTreePosition = _notes.isNotEmpty + ? _notes.map((n) => n.treePosition).reduce((a, b) => a > b ? a : b) + 1 + : 0; + final persistedTreePosition = data['nextTreePosition'] as int?; + _nextTreePosition = persistedTreePosition ?? fallbackTreePosition; + _hasPersistedTreePosition = persistedTreePosition != null; + _scannedBlockHashes = _decodeScannedBlockHashes(data['scannedBlockHashes']); + } + + Map _decodeScannedBlockHashes(Object? raw) { + final hashes = {}; + if (raw is Map) { + for (final entry in raw.entries) { + final height = entry.key is int + ? entry.key as int + : int.tryParse(entry.key.toString()); + final hash = entry.value?.toString(); + if (height != null && hash != null && hash.isNotEmpty) { + hashes[height] = hash; + } + } + } + return hashes; + } + + /// Save notes to storage (thread-safe public method). + Future save() async { + await _lock.synchronized(() async { + await _save(); + }); + } + + /// Internal save method (must be called within lock). + Future _save() async { + try { + _assertEncryptedStorageAvailable(); + + final path = await _storagePath; + final file = File(path); + + final data = { + 'lastSyncedHeight': _lastSyncedHeight, + 'nextDiversifierIndex': _nextDiversifierIndex, + 'notes': _notes.map((n) => n.toJson()).toList(), + 'addresses': _addresses.map((a) => a.toJson()).toList(), + 'scannedBlockHashes': _scannedBlockHashes + .map((height, hash) => MapEntry('$height', hash)), + }; + if (_hasPersistedTreePosition) { + data['nextTreePosition'] = _nextTreePosition; + } + + final encoded = jsonEncode(data); + if (allowUnencryptedStorage) { + await file.writeAsString(encoded); + } else { + await encryptionFileUtils! + .write(path: path, password: password!, data: encoded); + } + _lastSavedHeight = _lastSyncedHeight; + } catch (e) { + printV('[PIVX Sapling Storage] Failed to save encrypted sidecar'); + rethrow; + } + } + + /// Clear all notes and reset sync state (thread-safe). + /// Used for rescanning the blockchain. Also resets quarantine markers since + /// notes are dropped and rediscovered from chain data. + Future clear() async { + await _lock.synchronized(() async { + _notes = []; + _lastSyncedHeight = 0; + _nextTreePosition = 0; + _hasPersistedTreePosition = false; + _scannedBlockHashes = {}; + // Keep addresses, they're derived deterministically + await _save(); + }); + printV('[PIVX Sapling Storage] Cleared all notes for rescan'); + } + + /// Add a new note (thread-safe). + Future addNote(StoredSaplingNote note) async { + await _lock.synchronized(() async { + final existing = _notes.indexWhere((n) => n.id == note.id); + if (existing >= 0) { + final previous = _notes[existing]; + note.isSpent = note.isSpent || previous.isSpent; + note.isPendingSpend = note.isPendingSpend || previous.isPendingSpend; + note.isProvisionallySpent = + note.isProvisionallySpent || previous.isProvisionallySpent; + note.spendingTxid ??= previous.spendingTxid; + note.pendingSpendingTxid ??= previous.pendingSpendingTxid; + note.pendingSpendAt ??= previous.pendingSpendAt; + // a rescan re-decrypts a note the native engine restored without a memo, + // so keep the previously stored memo instead of nulling it. + note.memo ??= previous.memo; + note.blockTime ??= previous.blockTime; + _notes[existing] = note; + } else { + _notes.add(note); + } + await _save(); + }); + } + + /// Mark a note as spent (thread-safe). + Future markSpent(String noteId, String spendingTxid) async { + await _lock.synchronized(() async { + final note = _notes.firstWhere((n) => n.id == noteId); + note.isSpent = true; + note.isPendingSpend = false; + note.isProvisionallySpent = false; + note.spendingTxid = spendingTxid; + note.spendingHeight = null; + note.pendingSpendingTxid = null; + note.pendingSpendAt = null; + await _save(); + }); + } + + /// Mark notes spent by nullifier (thread-safe). + Future markSpentByNullifier( + String nullifier, + String spendingTxid, { + int? spendingHeight, + }) async { + return await _lock.synchronized(() async { + final note = _notes.cast().firstWhere( + (n) => n?.nullifier == nullifier, + orElse: () => null, + ); + + if (note != null) { + note.isSpent = true; + note.isPendingSpend = false; + note.isProvisionallySpent = false; + note.spendingTxid = spendingTxid; + note.spendingHeight = spendingHeight; + note.pendingSpendingTxid = null; + note.pendingSpendAt = null; + await _save(); + return true; + } + return false; + }); + } + + /// Record a server-reported spend for [nullifier] (thread-safe). + /// + /// A spend matching a locally broadcast (pending outgoing) transaction is + /// terminal, exactly like [markSpentByNullifier]. An unexpected spend is + /// quarantined instead: the note is marked provisionally spent, excluded + /// from spendable balance, and the marker is reversible by rescan + /// ([clear]) or reorg rewind, so a malicious ElectrumX server cannot + /// irreversibly freeze funds by fabricating spend events. + Future recordObservedSpendByNullifier( + String nullifier, + String spendingTxid, { + int? spendingHeight, + }) async { + return await _lock.synchronized(() async { + final note = _notes.cast().firstWhere( + (n) => n?.nullifier == nullifier, + orElse: () => null, + ); + + if (note == null) return false; + if (note.isSpent) return true; // Already terminal; nothing to change. + + if (note.isPendingSpend) { + // Matches a transaction this wallet broadcast: terminal spend. + note.isSpent = true; + note.isPendingSpend = false; + note.isProvisionallySpent = false; + note.pendingSpendingTxid = null; + note.pendingSpendAt = null; + } else { + // No local outgoing state for this nullifier: quarantine. + note.isProvisionallySpent = true; + } + note.spendingTxid = spendingTxid; + note.spendingHeight = spendingHeight; + await _save(); + return true; + }); + } + + /// Reserve notes by nullifier after a successful local broadcast. + /// + /// Reserved notes are excluded from spendable balance immediately, before the + /// spending nullifier appears in a later scanned block. + Future markPendingSpentByNullifiers( + List nullifiers, + String pendingTxid, + ) async { + if (nullifiers.isEmpty) return 0; + + return await _lock.synchronized(() async { + final pendingSet = nullifiers.toSet(); + var reservedValue = 0; + + for (final note in _notes) { + if (note.nullifier == null || !pendingSet.contains(note.nullifier)) { + continue; + } + if (note.isSpent) continue; + + note.isPendingSpend = true; + note.pendingSpendingTxid = pendingTxid; + note.pendingSpendAt = DateTime.now(); + reservedValue += note.value; + } + + if (reservedValue > 0) { + await _save(); + } + + return reservedValue; + }); + } + + /// Clear local pending-spend reservations. + /// + /// This is intended for debug/test recovery when a locally constructed + /// transaction was not accepted by the node but older code already reserved + /// its nullifiers. + Future clearPendingSpentNotes() async { + return await _lock.synchronized(() async { + var clearedValue = 0; + + for (final note in _notes) { + if (!note.isPendingSpend || note.isSpent) continue; + + note.isPendingSpend = false; + note.pendingSpendingTxid = null; + note.pendingSpendAt = null; + clearedValue += note.value; + } + + if (clearedValue > 0) { + await _save(); + } + + return clearedValue; + }); + } + + /// Release the pending-spend reservation for [spendingTxid] (an evicted or + /// reorged-out send) so the reserved notes are spendable again. Returns the + /// released value in zatoshis. Only touches notes reserved by this txid; + /// mined-spent notes (isSpent) are left alone. + Future releasePendingSpend(String spendingTxid) async { + return await _lock.synchronized(() async { + var releasedValue = 0; + for (final note in _notes) { + if (note.isSpent) continue; + if (note.pendingSpendingTxid != spendingTxid) continue; + note.isPendingSpend = false; + note.pendingSpendingTxid = null; + note.pendingSpendAt = null; + releasedValue += note.value; + } + if (releasedValue > 0) { + await _save(); + } + return releasedValue; + }); + } + + /// Update the last synced height (thread-safe). + Future setLastSyncedHeight(int height) async { + await _lock.synchronized(() async { + _lastSyncedHeight = height; + await _save(); + }); + } + + /// Update the next canonical Sapling tree position after processing outputs. + Future setNextTreePosition(int position) async { + await _lock.synchronized(() async { + if (position > _nextTreePosition) { + _nextTreePosition = position; + _hasPersistedTreePosition = true; + await _save(); + } + }); + } + + /// Update sync height and global tree cursor. The write is checkpointed: set + /// [flush] to force it (e.g. at the end of a sync pass), otherwise it only + /// hits disk once progress passes [_checkpointEveryBlocks]. + Future completeSyncRange({ + required int lastSyncedHeight, + required int nextTreePosition, + required bool treePositionIsTrusted, + Map blockHashes = const {}, + bool flush = false, + }) async { + await _lock.synchronized(() async { + _lastSyncedHeight = lastSyncedHeight; + if (nextTreePosition > _nextTreePosition) { + _nextTreePosition = nextTreePosition; + } + if (treePositionIsTrusted) { + _hasPersistedTreePosition = true; + } + _scannedBlockHashes.addAll(blockHashes); + _scannedBlockHashes.removeWhere((height, _) => height > lastSyncedHeight); + if (flush || + _lastSyncedHeight - _lastSavedHeight >= _checkpointEveryBlocks) { + await _save(); + } + }); + } + + /// Force the checkpointed sync state to disk (call at the end of a sync pass + /// so incremental polls persist their resume height). + Future flushSync() async { + await _lock.synchronized(() async { + if (_lastSyncedHeight != _lastSavedHeight) { + await _save(); + } + }); + } + + /// Rewind shielded state to [height] after a detected reorg. + /// + /// Notes created after the rewind point are removed. Spend markers observed + /// after that point are cleared so the rescan can re-apply the canonical + /// branch. The global tree cursor is intentionally marked untrusted because + /// the next sync must rely on explicit server positions after a rollback. + Future rewindToHeight(int height) async { + await _lock.synchronized(() async { + _notes.removeWhere((note) => note.height > height); + for (final note in _notes) { + if (note.spendingHeight != null && note.spendingHeight! > height) { + // A real local send that reorged out reverts to PENDING, keeping the + // txid so the disappeared-tx reconcile can re-check it before the note + // is spendable again. A quarantined phantom server spend has no real + // send behind it, so a reorg past the claimed height frees it fully. + final revertedTxid = note.spendingTxid; + final wasQuarantined = note.isProvisionallySpent; + note.isSpent = false; + note.isProvisionallySpent = false; + note.spendingTxid = null; + note.spendingHeight = null; + if (revertedTxid != null && !wasQuarantined) { + note.isPendingSpend = true; + note.pendingSpendingTxid = revertedTxid; + note.pendingSpendAt = DateTime.now(); + } + } + } + _lastSyncedHeight = height; + _nextTreePosition = 0; + _hasPersistedTreePosition = false; + _scannedBlockHashes.removeWhere((blockHeight, _) => blockHeight > height); + await _save(); + }); + } + + List getNotesInRange(int startHeight, int endHeight) { + return _notes + .where((n) => n.height >= startHeight && n.height <= endHeight) + .toList(); + } + + /// Add a new shielded address (thread-safe). + Future addAddress(StoredShieldedAddress address) async { + await _lock.synchronized(() async { + final existing = + _addresses.indexWhere((a) => a.address == address.address); + if (existing >= 0) { + _addresses[existing] = address; + } else { + _addresses.add(address); + } + if (address.diversifierIndex >= _nextDiversifierIndex) { + _nextDiversifierIndex = address.diversifierIndex + 1; + } + await _save(); + }); + } + + /// Get the next diversifier index and increment it. + /// + /// Synchronous (no awaits), so it cannot interleave with locked sections on + /// the single-threaded event loop; the next persisting write saves it. + int getAndIncrementDiversifierIndex() { + final index = _nextDiversifierIndex; + _nextDiversifierIndex++; + return index; + } + + /// Advance the next shielded receive index without moving it backwards + /// (thread-safe). + Future advanceNextDiversifierIndexAtLeast(int nextIndex) async { + await _lock.synchronized(() async { + if (nextIndex <= _nextDiversifierIndex) { + return; + } + + _nextDiversifierIndex = nextIndex; + await _save(); + }); + } + + /// Update an address label (thread-safe). + Future updateAddressLabel(String address, String? label) async { + await _lock.synchronized(() async { + final stored = _addresses.cast().firstWhere( + (a) => a?.address == address, + orElse: () => null, + ); + if (stored != null) { + stored.label = label; + await _save(); + } + }); + } + + StoredShieldedAddress? getAddressByEncoded(String address) { + return _addresses.cast().firstWhere( + (a) => a?.address == address, + orElse: () => null, + ); + } + + /// Clear addresses (keeps notes and sync state, thread-safe). + Future clearAddresses() async { + await _lock.synchronized(() async { + _addresses.clear(); + _nextDiversifierIndex = 1; + await _save(); + }); + } +} diff --git a/cw_pivx/lib/src/sapling/shield_sync_engine.dart b/cw_pivx/lib/src/sapling/shield_sync_engine.dart new file mode 100644 index 0000000000..93dfff187f --- /dev/null +++ b/cw_pivx/lib/src/sapling/shield_sync_engine.dart @@ -0,0 +1,228 @@ +/// Sapling shield synchronization engine: scans for incoming notes (trial +/// decryption), tracks spent notes (nullifiers), and maintains the commitment +/// tree and per-note witnesses for transaction building. +/// +/// Uses ElectrumX Sapling RPCs: +/// - `blockchain.sapling.get_outputs_by_height`: shielded outputs per block +/// - `blockchain.sapling.get_nullifier_status`: nullifier spent status +/// - `blockchain.sapling.get_best_anchor`: current best anchor +/// - `blockchain.sapling.get_commitment_info`: commitment tree state +/// - `blockchain.sapling.get_anchor_height`: block height for an anchor +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'sapling_note.dart'; +import 'sapling_key_manager.dart'; + +typedef SyncProgressCallback = void Function(SaplingSyncStatus status); + +class SaplingBlockData { + SaplingBlockData({ + required this.height, + required this.outputs, + required this.nullifiers, + this.timestamp, + }); + + final int height; + + /// Encrypted note ciphertexts. + final List outputs; + + /// Spent-note markers revealed in this block. + final List nullifiers; + + final int? timestamp; +} + +class SaplingOutput { + SaplingOutput({ + required this.cmu, + required this.ephemeralKey, + required this.ciphertext, + required this.txid, + required this.outputIndex, + }); + + /// Note commitment (32-byte hex); appended to the commitment tree. + final String cmu; + + /// Ephemeral public key for note decryption (32 bytes as hex). + final String ephemeralKey; + + /// Encrypted note ciphertext (580 bytes as hex). + final String ciphertext; + + final String txid; + + final int outputIndex; +} + +/// Sapling commitment tree: a depth-32 Merkle tree of all note commitments. +/// Tracks tree state (for anchors) and incremental witnesses (for spending). +abstract class SaplingCommitmentTree { + factory SaplingCommitmentTree() { + throw UnimplementedError( + 'SaplingCommitmentTree requires native implementation'); + } + + /// Append a note commitment [cmu] (32 bytes). + void append(Uint8List cmu); + + /// Current tree root/anchor (32-byte hash). + Uint8List get root; + + /// Number of commitments in the tree. + int get size; + + Uint8List serialize(); + + static SaplingCommitmentTree deserialize(Uint8List bytes) { + throw UnimplementedError( + 'SaplingCommitmentTree.deserialize requires native implementation'); + } +} + +/// Incremental witness: the Merkle path from a note to the tree root, updated +/// as new notes are added. +abstract class SaplingIncrementalWitness { + factory SaplingIncrementalWitness.fromTree(SaplingCommitmentTree tree) { + throw UnimplementedError( + 'SaplingIncrementalWitness requires native implementation'); + } + + void append(Uint8List cmu); + + /// Merkle path for this witness; null if not yet valid. + List? get path; + + int get position; + + /// Root at the time this witness was created. + Uint8List get root; + + Uint8List serialize(); + + static SaplingIncrementalWitness deserialize(Uint8List bytes) { + throw UnimplementedError( + 'SaplingIncrementalWitness.deserialize requires native implementation'); + } +} + +/// Shield sync engine for scanning and tracking Sapling notes. +abstract class ShieldSyncEngine { + ShieldSyncEngine({ + required this.keyManager, + required this.isTestnet, + }); + + final SaplingKeyManager keyManager; + + final bool isTestnet; + + int get lastSyncedBlock; + + int get currentBlockHeight; + + bool get isSyncing; + + SaplingSyncStatus get syncStatus; + + /// The total shielded balance in zatoshis. + int get balance; + + double get balancePivx => balance / 100000000.0; + + /// The pending (unconfirmed) balance in zatoshis. + int get pendingBalance; + + List get spendableNotes; + + List get spentNotes; + + SaplingCommitmentTree get commitmentTree; + + /// Load saved sync state and prepare for syncing. + Future initialize(); + + /// Start syncing; [startHeight] defaults to Sapling activation. + Future startSync({ + int? startHeight, + SyncProgressCallback? onProgress, + }); + + Future stopSync(); + + /// Clear notes after [height] and rescan. + Future rescan(int height); + + /// Spendable notes totaling >= [amount] (zatoshis), smallest-first for + /// consolidation. + List selectNotesForAmount(int amount, {int maxNotes = 10}); + + /// Whether [nullifier] is known (double-spend detection). + bool isNullifierKnown(String nullifier); + + /// Mark notes spent by nullifier, after broadcasting a transaction. + void markNotesSpent(List nullifiers, String txid); + + Uint8List get currentAnchor; + + Future getAnchorAtHeight(int height); + + Future save(); + + Future load(); + + void dispose(); +} + +/// Sapling-specific ElectrumX RPC methods the server must support. +abstract class ElectrumSaplingRpc { + /// RPC blockchain.sapling.get_outputs_by_height: outputs (cmu, epk, + /// ciphertext) for a block range. + Future> getOutputsByHeight( + int startHeight, int endHeight); + + /// RPC blockchain.sapling.get_nullifier_status: map of nullifier -> spent. + Future> getNullifierStatus(List nullifiers); + + /// RPC blockchain.sapling.get_commitment_info: tree state and anchor at a height. + Future getCommitmentInfo(int height); + + /// RPC blockchain.sapling.get_anchor_height: height where [anchor] was the root. + Future getAnchorHeight(String anchor); + + /// RPC blockchain.sapling.get_best_anchor: best anchor and its height. + Future getBestAnchor(); +} + +class CommitmentInfo { + CommitmentInfo({ + required this.height, + required this.root, + required this.size, + }); + + final int height; + + /// Tree root (anchor) at this height. + final String root; + + /// Number of commitments in the tree. + final int size; +} + +class AnchorInfo { + AnchorInfo({ + required this.anchor, + required this.height, + }); + + /// The anchor (tree root). + final String anchor; + + final int height; +} diff --git a/cw_pivx/lib/src/sapling/utils/atomic_tree_position.dart b/cw_pivx/lib/src/sapling/utils/atomic_tree_position.dart new file mode 100644 index 0000000000..efe0e53f29 --- /dev/null +++ b/cw_pivx/lib/src/sapling/utils/atomic_tree_position.dart @@ -0,0 +1,42 @@ +import 'package:synchronized/synchronized.dart'; + +/// Thread-safe, globally sequential position tracker for the Sapling commitment +/// tree. Positions must be gap-free and unique for Merkle-tree correctness, even +/// when sync batches run in parallel. +class AtomicTreePosition { + int _position = 0; + final Lock _lock = Lock(); + + /// Atomically reserve [count] consecutive positions; returns the start of the + /// reserved block. Concurrent calls never receive overlapping ranges. + Future reservePositions(int count) async { + return await _lock.synchronized(() { + final start = _position; + _position += count; + return start; + }); + } + + /// Move the next position forward without allowing it to go backwards. + Future setAtLeast(int position) async { + await _lock.synchronized(() { + if (position > _position) { + _position = position; + } + }); + } + + /// The NEXT position to be assigned; the last assigned is `current - 1`. + int get current => _position; + + /// Restore the counter from persistent storage at wallet init only; do not + /// call during sync. + void initialize(int position) { + _position = position; + } + + /// Reset to 0 (tests or full wallet resets only). + void reset() { + _position = 0; + } +} diff --git a/cw_pivx/linux/CMakeLists.txt b/cw_pivx/linux/CMakeLists.txt new file mode 100644 index 0000000000..0887b5449a --- /dev/null +++ b/cw_pivx/linux/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.10) +project(cw_pivx_plugin VERSION 0.0.1 LANGUAGES CXX) + +# Specify the C++ standard +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Set plugin name +set(PLUGIN_NAME "cw_pivx_plugin") + +# Define the plugin library +add_library(${PLUGIN_NAME} SHARED + cw_pivx_plugin.cc +) + +# Apply standard settings for a Flutter plugin +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden +) + +# Link against Flutter embedder +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include" +) +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) + +# Link the PIVX Sapling native library +target_link_libraries(${PLUGIN_NAME} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/lib/libcw_pivx_sapling.a + pthread + dl + m +) + +# Bundle the native library +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/lib/libcw_pivx_sapling.a" + DESTINATION "${CMAKE_INSTALL_PREFIX}/lib" +) + +# List of public headers +target_include_directories(${PLUGIN_NAME} PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" +) diff --git a/cw_pivx/linux/cw_pivx_plugin.cc b/cw_pivx/linux/cw_pivx_plugin.cc new file mode 100644 index 0000000000..1d4deaf873 --- /dev/null +++ b/cw_pivx/linux/cw_pivx_plugin.cc @@ -0,0 +1,64 @@ +#include "include/cw_pivx/cw_pivx_plugin.h" + +#include + +#include "cw_pivx_sapling.h" + +#define CW_PIVX_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), cw_pivx_plugin_get_type(), CwPivxPlugin)) + +struct _CwPivxPlugin { + GObject parent_instance; +}; + +G_DEFINE_TYPE(CwPivxPlugin, cw_pivx_plugin, g_object_get_type()) + +static void cw_pivx_plugin_handle_method_call( + CwPivxPlugin* self, + FlMethodCall* method_call) { + g_autoptr(FlMethodResponse) response = nullptr; + + const gchar* method = fl_method_call_get_name(method_call); + + if (strcmp(method, "getPlatformVersion") == 0) { + g_autofree gchar* version = g_strdup_printf("Linux"); + g_autoptr(FlValue) result = fl_value_new_string(version); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(result)); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); +} + +static void cw_pivx_plugin_dispose(GObject* object) { + G_OBJECT_CLASS(cw_pivx_plugin_parent_class)->dispose(object); +} + +static void cw_pivx_plugin_class_init(CwPivxPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = cw_pivx_plugin_dispose; +} + +static void cw_pivx_plugin_init(CwPivxPlugin* self) {} + +static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, + gpointer user_data) { + CwPivxPlugin* plugin = CW_PIVX_PLUGIN(user_data); + cw_pivx_plugin_handle_method_call(plugin, method_call); +} + +void cw_pivx_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + CwPivxPlugin* plugin = CW_PIVX_PLUGIN( + g_object_new(cw_pivx_plugin_get_type(), nullptr)); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + g_autoptr(FlMethodChannel) channel = + fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), + "cw_pivx", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(channel, method_call_cb, + g_object_ref(plugin), + g_object_unref); + + g_object_unref(plugin); +} diff --git a/cw_pivx/linux/include/cw_pivx/cw_pivx_plugin.h b/cw_pivx/linux/include/cw_pivx/cw_pivx_plugin.h new file mode 100644 index 0000000000..3c26eeb02f --- /dev/null +++ b/cw_pivx/linux/include/cw_pivx/cw_pivx_plugin.h @@ -0,0 +1,26 @@ +#ifndef FLUTTER_PLUGIN_CW_PIVX_PLUGIN_H_ +#define FLUTTER_PLUGIN_CW_PIVX_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _CwPivxPlugin CwPivxPlugin; +typedef struct { + GObjectClass parent_class; +} CwPivxPluginClass; + +FLUTTER_PLUGIN_EXPORT GType cw_pivx_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void cw_pivx_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_CW_PIVX_PLUGIN_H_ diff --git a/cw_pivx/linux/lib/README.md b/cw_pivx/linux/lib/README.md new file mode 100644 index 0000000000..d6b70ec00a --- /dev/null +++ b/cw_pivx/linux/lib/README.md @@ -0,0 +1,20 @@ +# PIVX Sapling Native Library for Linux + +This directory contains the native PIVX Sapling library for Linux. + +## Building + +Run the build script from the cw_pivx directory: + +```bash +./scripts/build_linux.sh +``` + +This will: +1. Build the Rust library for Linux +2. Copy libcw_pivx_sapling.a to this directory + +## Contents + +After building: +- `libcw_pivx_sapling.a` - Static library for Linux diff --git a/cw_pivx/macos/Classes/CwPivxPlugin.swift b/cw_pivx/macos/Classes/CwPivxPlugin.swift new file mode 100644 index 0000000000..f8b481a56d --- /dev/null +++ b/cw_pivx/macos/Classes/CwPivxPlugin.swift @@ -0,0 +1,19 @@ +import Cocoa +import FlutterMacOS + +public class CwPivxPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "cw_pivx", binaryMessenger: registrar.messenger) + let instance = CwPivxPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/cw_pivx/macos/Frameworks/README.md b/cw_pivx/macos/Frameworks/README.md new file mode 100644 index 0000000000..538f662366 --- /dev/null +++ b/cw_pivx/macos/Frameworks/README.md @@ -0,0 +1,29 @@ +# PIVX Sapling Native Library for macOS + +This directory contains the native PIVX Sapling library for macOS. + +## Building + +Run the build script from the cw_pivx directory: + +```bash +./scripts/build_macos.sh +``` + +This will: +1. Build the Rust library for macOS arm64 (Apple Silicon) +2. Build for macOS x86_64 (Intel) +3. Create a universal binary using lipo +4. Copy to this directory + +## Contents + +After building: +- `libcw_pivx_sapling.a` - Universal static library (arm64 + x86_64) +- `cw_pivx_sapling.h` - C header file + +## Requirements + +- Rust (with cargo) +- rustup targets: `aarch64-apple-darwin`, `x86_64-apple-darwin` +- Xcode Command Line Tools (for lipo) diff --git a/cw_pivx/macos/Frameworks/cw_pivx_sapling.h b/cw_pivx/macos/Frameworks/cw_pivx_sapling.h new file mode 100644 index 0000000000..ab98231f72 --- /dev/null +++ b/cw_pivx/macos/Frameworks/cw_pivx_sapling.h @@ -0,0 +1,373 @@ +/* PIVX Sapling FFI - Auto-generated by cbindgen */ + +#ifndef CW_PIVX_SAPLING_H +#define CW_PIVX_SAPLING_H + +/* Generated with cbindgen:0.29.0 */ + +/* Warning: this file was auto-generated by cbindgen. Don't modify this manually. */ + +#include +#include +#include +#include + +/** + * Sapling tree depth constant. + */ +#define SAPLING_TREE_DEPTH 32 + +/** + * PIVX max supply: 21,000,000 coins = 21,000,000,000,000 zatoshis (21 trillion zatoshis). + */ +#define PIVX_MAX_SUPPLY 21000000000000ull + +/** + * Shielded dust threshold derived from PIVX Core v5.6.1: + * 100 * dustRelayFee.GetFee(384-byte spend + 34-byte txout + 64-byte binding sig). + */ +#define SHIELDED_DUST_THRESHOLD 1446000ull + +/** + * Transparent dust threshold derived from PIVX Core v5.6.1: + * dustRelayFee.GetFee(182) with dust relay fee 30,000 zatoshis/kB. + */ +#define TRANSPARENT_DUST_THRESHOLD 5460ull + +/** + * PIVX Sapling activation height. + */ +#define PIVX_SAPLING_ACTIVATION 2700500 + +#define PIVX_TESTNET_SAPLING_ACTIVATION 201 + +/** + * Default Bitcoin/PIVX sequence number. + */ +#define TransparentInput_SEQUENCE_FINAL 4294967295 + +/** + * FFI buffer for returning binary data. + */ +typedef struct FFIBuffer { + uint8_t *data; + uintptr_t len; +} FFIBuffer; + +/** + * Build a shielded transaction with explicit note and witness data. + * + * This is the more complete transaction building function that accepts + * pre-computed witnesses from the caller (typically fetched from ElectrumX). + * + * # Parameters + * * `key_handle` - Handle from cw_pivx_init_keys + * * `notes_json` - JSON array of spendable notes with witnesses + * * `to_address` - Recipient address (ps1... format) + * * `amount` - Amount in zatoshis + * * `memo` - Optional memo (512 bytes max, null for none) + * * `fee` - Fee in zatoshis + * * `anchor_hex` - Current anchor (merkle root) as 32-byte hex + * + * # Returns + * FFIBuffer containing JSON with txid and tx_hex, or empty on error + * Build a transparent-to-shielded (t-to-z, shield) transaction. + * + * `utxos_json` is an array of objects with `txid` (display hex), `vout`, + * `value`, `script_pubkey` (hex, P2PKH) and `private_key` (32-byte hex). + * `change` of zero means no transparent change output; otherwise + * `change_address` receives it. Amounts must balance exactly: + * sum(utxos) = amount + change + fee. + */ +struct FFIBuffer cw_pivx_build_shield_tx(int64_t key_handle, + const char *utxos_json, + const char *to_address, + uint64_t amount, + const char *memo, + uint64_t fee, + const char *change_address, + uint64_t change); + +struct FFIBuffer cw_pivx_build_shielded_tx(int64_t key_handle, + const char *notes_json, + const char *to_address, + uint64_t amount, + const char *memo, + uint64_t fee, + const char *anchor_hex); + +/** + * Check if a nullifier matches any of our notes and mark them spent. + * + * # Parameters + * * `sync_handle` - Handle from cw_pivx_init_sync_engine + * * `nullifier` - 32-byte nullifier to check + * + * # Returns + * 1 if a note was marked spent, 0 otherwise. + */ +uint8_t cw_pivx_check_nullifier(int64_t sync_handle, + const uint8_t *nullifier); + +/** + * Derive an address at a specific index. + */ +char *cw_pivx_derive_address(int64_t handle, + uint64_t index); + +/** + * Dispose keys. + */ +void cw_pivx_dispose_keys(int64_t handle); + +/** + * Free the prover and release memory (~50MB). + */ +void cw_pivx_dispose_prover(void); + +/** + * Dispose sync engine. + */ +void cw_pivx_dispose_sync_engine(int64_t handle); + +/** + * Estimate transaction fee. + * Returns the estimated fee in zatoshis, or u64::MAX if overflow would occur. + */ +uint64_t cw_pivx_estimate_fee(uintptr_t spends, + uintptr_t outputs, + uintptr_t t_inputs, + uintptr_t t_outputs); + +/** + * Free a buffer allocated by this library. + */ +void cw_pivx_free_buffer(struct FFIBuffer buffer); + +/** + * Free a string allocated by this library. + */ +void cw_pivx_free_string(char *ptr); + +/** + * Get the default payment address. + */ +char *cw_pivx_get_default_address(int64_t handle); + +/** + * Get and clear the last error message. + */ +char *cw_pivx_get_last_error(void); + +/** + * Get the shielded balance. + */ +uint64_t cw_pivx_get_shielded_balance(int64_t handle); + +/** + * Get all spendable notes from the sync state as JSON. + * + * Returns a JSON array of note objects, each containing all data + * needed for transaction building including the rseed and diversifier. + * + * # Parameters + * * `sync_handle` - Handle from cw_pivx_init_sync_engine + * + * # Returns + * JSON string with note data, or null on error. + * Caller must free with cw_pivx_free_string. + */ +char *cw_pivx_get_spendable_notes(int64_t sync_handle); + +/** + * Get the current sync height. + */ +uint32_t cw_pivx_get_sync_height(int64_t handle); + +/** + * Get the number of unspent notes. + */ +uintptr_t cw_pivx_get_unspent_note_count(int64_t handle); + +/** + * Get the full viewing key. + */ +char *cw_pivx_get_viewing_key(int64_t handle); + +/** + * Check if proving parameters are available. + */ +uint8_t cw_pivx_has_proving_params(const char *path); + +/** + * Initialize keys from a seed. + * Returns a handle for future operations, or -1 on error. + */ +int64_t cw_pivx_init_keys(const uint8_t *seed, + uintptr_t seed_len, + uint8_t is_testnet); + +/** + * Initialize the Groth16 prover with the proving parameters. + * + * This loads the ~50MB proving parameter files into memory. + * Should be called once before any transaction building. + * + * # Parameters + * * `params_dir` - Path to directory containing sapling-spend.params and sapling-output.params + * + * # Returns + * 0 on success, negative on error + */ +int32_t cw_pivx_init_prover(const char *params_dir); + +/** + * Initialize sync engine. + */ +int64_t cw_pivx_init_sync_engine(uint8_t _is_testnet); + +/** + * Check if the prover is initialized. + */ +uint8_t cw_pivx_is_prover_initialized(void); + +/** + * Reset sync state. + */ +void cw_pivx_reset_sync(int64_t handle); + +/** + * Restore a note from JSON data. + * + * This allows restoring notes from persistent storage after app restart. + * The JSON should contain the same fields returned by cw_pivx_get_spendable_notes. + * + * # Parameters + * * `key_handle` - Handle from cw_pivx_init_keys + * * `sync_handle` - Handle from cw_pivx_init_sync_engine + * * `note_json` - JSON string with note data + * + * # Returns + * 1 on success, 0 on failure + */ +int32_t cw_pivx_restore_note(int64_t key_handle, + int64_t sync_handle, + const char *note_json); + +/** + * Update sync height after processing a block. + */ +void cw_pivx_set_sync_height(int64_t sync_handle, + uint32_t height); + +/** + * Try to decrypt a Sapling output and add to sync state if successful. + * + * This is the core function for detecting incoming shielded transactions. + * It attempts trial decryption of a Sapling output using the wallet's + * incoming viewing key. + * + * # Parameters + * * `key_handle` - Handle from cw_pivx_init_keys + * * `sync_handle` - Handle from cw_pivx_init_sync_engine + * * `cmu` - Note commitment (32 bytes) + * * `epk` - Ephemeral public key (32 bytes) + * * `enc_ciphertext` - Encrypted ciphertext (580 bytes) + * * `height` - Block height + * * `tx_index` - Transaction index in block + * * `output_index` - Output index in transaction + * * `position` - Position in commitment tree + * + * # Returns + * The note value in zatoshis if decryption succeeds, 0 otherwise. + */ +uint64_t cw_pivx_try_decrypt_output(int64_t key_handle, + int64_t sync_handle, + const uint8_t *cmu, + const uint8_t *epk, + const uint8_t *enc_ciphertext, + uint32_t height, + uint32_t tx_index, + uint32_t output_index, + uint64_t position); + +/** + * Validate a Sapling address. + */ +uint8_t cw_pivx_validate_address(const char *address, + uint8_t is_testnet); + +/** + * Get the library version. + */ +char *cw_pivx_version(void); + +/** + * Clear the last error. + */ +void pivx_clear_last_error(void); + +/** + * Free a byte buffer allocated by this library. + * + * # Safety + * The pointer must have been allocated by this library and not already freed. + */ +void pivx_free_buffer(unsigned char *ptr, + uintptr_t len); + +/** + * Free a string allocated by this library. + * + * # Safety + * The pointer must have been allocated by this library and not already freed. + */ +void pivx_free_string(char *s); + +/** + * Get the last error message. + * Returns null if no error occurred. + * Caller must free the returned string with `pivx_free_string`. + */ +char *pivx_get_last_error(void); + +int32_t pivx_sapling_create_from_seed(const uint8_t *seed, + uintptr_t seed_len, + int32_t is_testnet, + int32_t *session_id); + +int32_t pivx_sapling_destroy(int32_t session_id); + +void pivx_sapling_free_string(char *ptr); + +int64_t pivx_sapling_get_balance(int32_t session_id); + +int32_t pivx_sapling_get_sync_height(int32_t session_id); + +int32_t pivx_sapling_init(void); + +/** + * Verify that a server-supplied witness recomputes to the expected anchor. + * + * * `witness_hex` - 32 sibling hashes as hex (2048 hex chars), the same + * serialization `cw_pivx_build_shielded_tx` parses into a spend path. + * * `cmu_hex` - 32-byte note commitment as hex. + * * `anchor_hex` - 32-byte expected anchor (Merkle root) as hex. + * * `position` - Position of the note in the commitment tree. + * + * Returns 1 when the locally recomputed root equals the anchor, 0 on a + * clean mismatch, and -1 on parse or other errors (see + * `cw_pivx_get_last_error`). + */ +int32_t pivx_sapling_verify_witness_root(const char *witness_hex, + const char *cmu_hex, + const char *anchor_hex, + uint64_t position); + +/** + * Get the library version string. + * Caller must free the returned string with `pivx_free_string`. + */ +char *pivx_sapling_version(void); + +#endif /* CW_PIVX_SAPLING_H */ diff --git a/cw_pivx/macos/cw_pivx.podspec b/cw_pivx/macos/cw_pivx.podspec new file mode 100644 index 0000000000..c0010eca94 --- /dev/null +++ b/cw_pivx/macos/cw_pivx.podspec @@ -0,0 +1,31 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint cw_pivx.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'cw_pivx' + s.version = '0.0.1' + s.summary = 'PIVX integration for Cake Wallet with Sapling support.' + s.description = <<-DESC +PIVX cryptocurrency integration for Cake Wallet, including +full Sapling shielded transaction support via native Rust library. + DESC + s.homepage = 'https://cakewallet.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Cake Wallet' => 'support@cakewallet.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + s.platform = :osx, '10.14' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'OTHER_LDFLAGS' => '-lresolv' + } + s.swift_version = '5.0' + + # Link the Sapling native library + s.vendored_libraries = 'Frameworks/libcw_pivx_sapling.a' + s.preserve_paths = 'Frameworks/**/*' + +end diff --git a/cw_pivx/pubspec.yaml b/cw_pivx/pubspec.yaml new file mode 100644 index 0000000000..3619c7b10b --- /dev/null +++ b/cw_pivx/pubspec.yaml @@ -0,0 +1,67 @@ +name: cw_pivx +description: "PIVX wallet for Cake Wallet, with Sapling shielded support" +version: 0.0.1 +publish_to: none +homepage: https://cakewallet.com + +environment: + sdk: '>=2.19.0 <3.0.0' + flutter: ">=1.20.0" + +dependencies: + flutter: + sdk: flutter + bip39: ^1.0.6 + bip32: ^2.0.0 + path_provider: ^2.0.11 + mobx: ^2.0.7+4 + flutter_mobx: ^2.0.6+1 + ffi: ^2.1.0 + crypto: ^3.0.2 + bech32: + git: + url: https://github.com/cake-tech/bech32.git + synchronized: ^3.1.0 + hive: ^2.2.3 + cw_core: + path: ../cw_core + cw_bitcoin: + path: ../cw_bitcoin + + blockchain_utils: + git: + url: https://github.com/cake-tech/blockchain_utils + ref: 59fdf29d72068e0522a96a8953ed7272833a9f57 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.15.0 + mobx_codegen: ^2.0.7 +# hive_generator: ^2.0.1 + +dependency_overrides: + watcher: ^1.1.0 + bitcoin_base: + git: + url: https://github.com/Liquid369/bitcoin_base + ref: 490c1b644090685a49b2952b2b1205f0b4c50e84 + pointycastle: 3.7.4 + ffi: 2.1.0 + intl: any + +flutter: + uses-material-design: true + assets: + - assets/params/ + plugin: + platforms: + ios: + pluginClass: CwPivxPlugin + macos: + pluginClass: CwPivxPlugin + android: + package: com.cakewallet.cw_pivx + pluginClass: CwPivxPlugin + linux: + pluginClass: CwPivxPlugin diff --git a/cw_pivx/rust/.gitignore b/cw_pivx/rust/.gitignore new file mode 100644 index 0000000000..6afd771731 --- /dev/null +++ b/cw_pivx/rust/.gitignore @@ -0,0 +1,26 @@ +# Rust build artifacts +/target/ +Cargo.lock + +# Generated files +*.so +*.dylib +*.a +*.dll +*.xcframework/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store + +# Build output directories +/build/ +/out/ + +# cbindgen generated +/include/ diff --git a/cw_pivx/rust/Cargo.toml b/cw_pivx/rust/Cargo.toml new file mode 100644 index 0000000000..c772ee4903 --- /dev/null +++ b/cw_pivx/rust/Cargo.toml @@ -0,0 +1,89 @@ +[package] +name = "cw_pivx_sapling" +version = "0.1.0" +edition = "2021" +authors = ["Cake Wallet Team"] +description = "PIVX Sapling native library for Cake Wallet" +license = "MIT" + +[lib] +crate-type = ["cdylib", "staticlib"] +name = "cw_pivx_sapling" + +[features] +default = [] +# Enable for iOS/macOS static linking +static = [] + +[dependencies] +# Core Sapling cryptography from PIVX's fork of librustzcash +# These provide the actual Sapling protocol implementation +zcash_primitives = { git = "https://github.com/Duddino/librustpivx", branch = "librustzcash-rebase", package = "zcash_primitives", default-features = false, features = ["transparent-inputs", "std"] } +zcash_proofs = { git = "https://github.com/Duddino/librustpivx", branch = "librustzcash-rebase", package = "zcash_proofs", default-features = false, features = ["local-prover"] } +zcash_client_backend = { git = "https://github.com/Duddino/librustpivx", branch = "librustzcash-rebase", package = "zcash_client_backend" } +zcash_keys = { git = "https://github.com/Duddino/librustpivx", branch = "librustzcash-rebase", package = "zcash_keys", features = ["unstable"] } +zcash_protocol = { git = "https://github.com/Duddino/librustpivx", branch = "librustzcash-rebase", package = "zcash_protocol", default-features = false } +zcash_note_encryption = { version = "0.4", features = ["pre-zip-212"] } +sapling = { git = "https://github.com/Duddino/librustpivx", branch = "librustzcash-rebase" } +bellman = { git = "https://github.com/Duddino/librustpivx", branch = "librustzcash-rebase", features = ["groth16"] } +bls12_381 = "0.8" +group = "0.13" +incrementalmerkletree = "0.7" + +# Cryptographic primitives +rand_core = "0.6" +getrandom = { version = "0.2", features = ["js"] } +sha2 = "0.10" +ripemd = "0.1" +secp256k1 = "0.29" +blake2b_simd = "1.0" +zeroize = { version = "1.7", features = ["zeroize_derive"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +hex = "0.4" + +# FFI support +libc = "0.2" + +# Error handling +thiserror = "1.0" +anyhow = "1.0" + +# Lazy static for global state +lazy_static = "1.4" + +# Bech32 encoding +bech32 = "0.9" + +# Additional dependencies +rand = "0.8" +jubjub = "0.10" + +# Logging (for debugging) +log = "0.4" +env_logger = "0.10" + +[build-dependencies] +cbindgen = "0.26" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" + +# Dependencies (jubjub, sapling, ff/group, chacha20poly1305, ...) carry the +# Sapling trial-decryption hot path, so build them for speed, not size. opt-level +# "z" here made shielded sync markedly slower on mobile ARM; the few extra MB in +# the .so are negligible in the app bundle. +[profile.release.package."*"] +opt-level = 3 + +# Patches required to match librustpivx dependencies +[patch.crates-io] +orchard = { git = "https://github.com/zcash/orchard.git", rev = "c684e9185a0449efb00428f807d3bf286b5dae03" } +redjubjub = { git = "https://github.com/ZcashFoundation/redjubjub", rev = "eae848c5c14d9c795d000dd9f4c4762d1aee7ee1" } +# Force bellman to use PIVX fork - the sapling crate incorrectly pulls from crates.io +bellman = { git = "https://github.com/Duddino/librustpivx", branch = "librustzcash-rebase" } diff --git a/cw_pivx/rust/cbindgen.toml b/cw_pivx/rust/cbindgen.toml new file mode 100644 index 0000000000..0e73cf9ae7 --- /dev/null +++ b/cw_pivx/rust/cbindgen.toml @@ -0,0 +1,44 @@ +# cbindgen configuration for cw_pivx_sapling +# Generates C headers for FFI bindings + +language = "C" + +# Output settings +header = "/* PIVX Sapling FFI - Auto-generated by cbindgen */" +include_guard = "CW_PIVX_SAPLING_H" +autogen_warning = "/* Warning: this file was auto-generated by cbindgen. Don't modify this manually. */" +include_version = true +cpp_compat = false + +[parse] +parse_deps = false + +[export] +include = [] +exclude = [] +prefix = "" + +[fn] +args = "Vertical" +sort_by = "Name" + +[struct] +derive_constructor = false +derive_eq = false +derive_neq = false +derive_lt = false +derive_lte = false +derive_gt = false +derive_gte = false + +[enum] +add_sentinel = false +prefix_with_name = false +derive_helper_methods = false +derive_const_casts = false +derive_mut_casts = false + +[const] +allow_static_const = true + +[defines] diff --git a/cw_pivx/rust/src/error.rs b/cw_pivx/rust/src/error.rs new file mode 100644 index 0000000000..0f336e4d8d --- /dev/null +++ b/cw_pivx/rust/src/error.rs @@ -0,0 +1,71 @@ +//! Error types for the PIVX Sapling library. + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum SaplingError { + #[error("Invalid seed")] + InvalidSeed, + + #[error("Invalid key")] + InvalidKey, + + #[error("Invalid address")] + InvalidAddress, + + #[error("Invalid diversifier")] + InvalidDiversifier, + + #[error("Invalid input: {0}")] + InvalidInput(String), + + #[error("Key derivation failed")] + KeyDerivation, + + #[error("Encoding error")] + Encoding, + + #[error("Decoding error")] + Decoding, + + #[error("Note decryption failed")] + NoteDecryption, + + #[error("Transaction building failed")] + TransactionBuild, + + #[error("Proof error: {0}")] + ProofError(String), + + #[error("Invalid witness")] + InvalidWitness, + + #[error("Witness not found")] + WitnessNotFound, + + #[error("Tree error")] + TreeError, + + #[error("Invalid anchor")] + InvalidAnchor, + + #[error("Insufficient funds")] + InsufficientFunds, + + #[error("Prover not initialized")] + ProverNotInitialized, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Hex decoding error: {0}")] + Hex(#[from] hex::FromHexError), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Internal error: {0}")] + Internal(String), +} + +pub type Result = std::result::Result; diff --git a/cw_pivx/rust/src/ffi.rs b/cw_pivx/rust/src/ffi.rs new file mode 100644 index 0000000000..6d901b6dad --- /dev/null +++ b/cw_pivx/rust/src/ffi.rs @@ -0,0 +1,1994 @@ +//! FFI bindings for Dart/Flutter integration. +//! +//! This module provides C-compatible FFI functions that can be called +//! from Dart using dart:ffi. +//! +//! Function names use `cw_pivx_*` prefix for Cake Wallet compatibility. + +use group::GroupEncoding; +use lazy_static::lazy_static; +use std::ffi::{c_char, CStr, CString}; +use std::ptr; +use std::slice; +use std::sync::Mutex; +use zeroize::Zeroize; + +use sapling::{ + keys::PreparedIncomingViewingKey, + note::ExtractedNoteCommitment, + note_encryption::{try_sapling_note_decryption, SaplingDomain, Zip212Enforcement}, + Node, +}; +use zcash_note_encryption::{EphemeralKeyBytes, ShieldedOutput, ENC_CIPHERTEXT_SIZE}; + +use crate::keys::{validate_address, SaplingKeyManager}; +use crate::notes::SpendableNote; +use crate::sync::SyncState; +use crate::types::Network; + +/// A simple output wrapper that implements ShieldedOutput for trial decryption. +/// This allows us to trial decrypt outputs from raw bytes without needing +/// a full OutputDescription with proof. +struct TrialDecryptionOutput { + ephemeral_key: EphemeralKeyBytes, + cmu: ExtractedNoteCommitment, + enc_ciphertext: [u8; ENC_CIPHERTEXT_SIZE], +} + +impl ShieldedOutput for TrialDecryptionOutput { + fn ephemeral_key(&self) -> EphemeralKeyBytes { + self.ephemeral_key.clone() + } + + fn cmstar_bytes(&self) -> [u8; 32] { + self.cmu.to_bytes() + } + + fn enc_ciphertext(&self) -> &[u8; ENC_CIPHERTEXT_SIZE] { + &self.enc_ciphertext + } +} + +lazy_static! { + static ref KEY_MANAGERS: Mutex>> = Mutex::new(Vec::new()); + static ref SYNC_STATES: Mutex>> = Mutex::new(Vec::new()); + static ref LAST_ERROR: Mutex> = Mutex::new(None); +} + +/// Helper macro for acquiring mutex locks with poison handling. +/// If a mutex is poisoned (thread panicked while holding it), this returns +/// an appropriate error value instead of panicking the entire application. +/// +/// Usage: lock_or_fail!(MUTEX, error_return_value) +macro_rules! lock_or_fail { + ($mutex:expr, $error_return:expr) => { + match $mutex.lock() { + Ok(guard) => guard, + Err(_poisoned) => { + // Mutex is poisoned, a previous thread panicked while holding it. + // We cannot safely use the data, so return an error. + // Use set_error_safe to avoid recursive poison if LAST_ERROR is also poisoned. + set_error_safe("Internal state corrupted (mutex poisoned). Please restart wallet."); + return $error_return; + } + } + }; +} + +/// Safely set error message, handling the case where LAST_ERROR mutex itself is poisoned. +/// This prevents cascading panics when reporting errors. +fn set_error_safe(msg: &str) { + if let Ok(mut error) = LAST_ERROR.lock() { + *error = Some(msg.to_string()); + } + // If LAST_ERROR is poisoned, silently fail; we cannot report the error, + // but at least we don't crash the application. +} + +/// Set the last error message. +/// Use this for normal error reporting. If the mutex is poisoned, this will +/// set a generic "corrupted state" error instead of the specific message. +fn set_error(msg: &str) { + let mut error = lock_or_fail!(LAST_ERROR, ()); + *error = Some(msg.to_string()); +} + +/// Safely convert an i64 handle to usize index with validation. +/// Returns None if the handle is negative or too large for usize. +fn handle_to_index(handle: i64) -> Option { + if handle < 0 { + return None; + } + // On 32-bit systems, check if handle fits in usize + #[cfg(target_pointer_width = "32")] + { + if handle > usize::MAX as i64 { + return None; + } + } + Some(handle as usize) +} + +/// Macro to validate a handle and get the corresponding item from a Vec>. +/// Returns with error_return if handle is invalid or out of bounds. +macro_rules! get_from_handle { + ($handle:expr, $vec:expr, $error_return:expr, $item_name:expr) => {{ + let idx = match handle_to_index($handle) { + Some(i) => i, + None => { + set_error(&format!("Invalid {}: must be non-negative", $item_name)); + return $error_return; + } + }; + match $vec.get(idx).and_then(|item| item.as_ref()) { + Some(item) => item, + None => { + if idx >= $vec.len() { + set_error(&format!("Invalid {}: out of range", $item_name)); + } else { + set_error(&format!("{} has been disposed", $item_name)); + } + return $error_return; + } + } + }}; +} + +/// Get and clear the last error message. +#[no_mangle] +pub extern "C" fn cw_pivx_get_last_error() -> *mut c_char { + let mut error = lock_or_fail!(LAST_ERROR, ptr::null_mut()); + match error.take() { + Some(msg) => { + // Replace null bytes with spaces if any (should never happen in error messages) + let sanitized = msg.replace('\0', " "); + CString::new(sanitized) + .expect("Error message sanitized: no null bytes") + .into_raw() + } + None => ptr::null_mut(), + } +} + +/// Helper to safely extract fixed-size byte array from FFI pointer. +/// Returns error string if pointer is null or size doesn't match. +unsafe fn bytes_from_ffi_ptr( + ptr: *const u8, + param_name: &str, +) -> Result<[u8; N], String> { + if ptr.is_null() { + return Err(format!("{} is null", param_name)); + } + let slice = slice::from_raw_parts(ptr, N); + slice + .try_into() + .map_err(|_| format!("{} size mismatch (expected {} bytes)", param_name, N)) +} + +/// Validate fee is reasonable. +/// Fee must be at least 10,000 zatoshis (0.0001 PIV). No absolute upper cap: +/// the Dart policy scales the fee with serialized size (100 zat/byte), so a +/// large shield/unshield (many inputs/outputs) legitimately exceeds 1 PIV, and +/// PIVX Core imposes no absolute fee cap. +fn validate_fee(fee: u64) -> Result<(), String> { + const MIN_FEE: u64 = 10_000; // 0.0001 PIV + if fee < MIN_FEE { + return Err(format!("Fee too low (min {} zatoshis)", MIN_FEE)); + } + Ok(()) +} + +/// Validate a shielded output amount. +/// +/// PIVX Core v5.6.1 computes shielded dust as: +/// DEFAULT_SHIELDEDTXFEE_K * dustRelayFee.GetFee(SPENDDESCRIPTION_SIZE +/// + CTXOUT_REGULAR_SIZE + BINDINGSIG_SIZE), which is 1,446,000 zatoshis. +fn validate_shielded_amount(amount: u64, param_name: &str) -> Result<(), String> { + const MAX_REASONABLE: u64 = 10_000_000_000_000_000_000; // 100 billion PIV + const SHIELDED_DUST_THRESHOLD: u64 = 1_446_000; + + if amount == 0 { + return Err(format!("{} cannot be zero", param_name)); + } + if amount < SHIELDED_DUST_THRESHOLD { + return Err(format!( + "{} is below shielded dust threshold ({} zatoshis)", + param_name, SHIELDED_DUST_THRESHOLD + )); + } + if amount > MAX_REASONABLE { + return Err(format!("{} exceeds maximum reasonable amount", param_name)); + } + Ok(()) +} + +/// Validate string length is within reasonable bounds. +/// This prevents DoS attacks via extremely long strings. +fn validate_string_length(s: &str, max_len: usize, param_name: &str) -> Result<(), String> { + if s.len() > max_len { + return Err(format!( + "{} too long (max {} chars, got {})", + param_name, + max_len, + s.len() + )); + } + Ok(()) +} + +/// Validate memo length (max 512 bytes for Sapling). +fn validate_memo(memo: Option<&str>) -> Result<(), String> { + if let Some(m) = memo { + if m.len() > 512 { + return Err(format!("Memo too long (max 512 bytes, got {})", m.len())); + } + } + Ok(()) +} + +#[no_mangle] +pub extern "C" fn cw_pivx_version() -> *mut c_char { + CString::new(env!("CARGO_PKG_VERSION")) + .expect("Version string is valid: no null bytes") + .into_raw() +} + +/// Overwrite FFI-owned memory before returning it to the allocator. +pub(crate) unsafe fn zero_ffi_allocation(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + + for offset in 0..len { + ptr.add(offset).write_volatile(0); + } +} + +/// Copy a Rust-owned string into an FFI buffer, then zero the Rust staging copy. +fn ffi_buffer_from_string(mut value: String) -> Option { + let len = value.len(); + let data = unsafe { + let ptr = libc::malloc(len) as *mut u8; + if ptr.is_null() { + value.zeroize(); + return None; + } + ptr::copy_nonoverlapping(value.as_ptr(), ptr, len); + ptr + }; + + value.zeroize(); + Some(FFIBuffer { data, len }) +} + +/// Free a string allocated by this library. +#[no_mangle] +pub extern "C" fn cw_pivx_free_string(ptr: *mut c_char) { + if !ptr.is_null() { + unsafe { + let len = CStr::from_ptr(ptr).to_bytes_with_nul().len(); + zero_ffi_allocation(ptr.cast::(), len); + let _ = CString::from_raw(ptr); + } + } +} + +// Also provide the old name for compatibility +#[no_mangle] +pub extern "C" fn pivx_sapling_free_string(ptr: *mut c_char) { + cw_pivx_free_string(ptr); +} + +/// FFI buffer for returning binary data. +#[repr(C)] +pub struct FFIBuffer { + pub data: *mut u8, + pub len: usize, +} + +/// Free a buffer allocated by this library. +#[no_mangle] +pub extern "C" fn cw_pivx_free_buffer(buffer: FFIBuffer) { + if !buffer.data.is_null() && buffer.len > 0 { + unsafe { + zero_ffi_allocation(buffer.data, buffer.len); + libc::free(buffer.data.cast::()); + } + } +} + +/// Initialize keys from a seed. +/// Returns a handle for future operations, or -1 on error. +#[no_mangle] +pub extern "C" fn cw_pivx_init_keys(seed: *const u8, seed_len: usize, is_testnet: u8) -> i64 { + if seed.is_null() || seed_len < 32 { + set_error("Invalid seed"); + return -1; + } + + let seed_slice = unsafe { slice::from_raw_parts(seed, seed_len) }; + let network = if is_testnet != 0 { + Network::Testnet + } else { + Network::Mainnet + }; + + match SaplingKeyManager::from_seed(seed_slice, network) { + Ok(manager) => { + let mut managers = lock_or_fail!(KEY_MANAGERS, -1); + + // Find first available slot (reuse disposed handles) or create new + let id = managers + .iter() + .position(|m| m.is_none()) + .unwrap_or_else(|| { + managers.push(None); + managers.len() - 1 + }) as i64; + + managers[id as usize] = Some(manager); + + // Pair a sync state at the same index. + let mut states = lock_or_fail!(SYNC_STATES, -1); + while states.len() <= id as usize { + states.push(None); + } + states[id as usize] = Some(SyncState::new()); + + id + } + Err(e) => { + set_error(&format!("Failed to create key manager: {:?}", e)); + -1 + } + } +} + +/// Dispose keys. +#[no_mangle] +pub extern "C" fn cw_pivx_dispose_keys(handle: i64) { + let idx = match handle_to_index(handle) { + Some(i) => i, + None => { + set_error("Invalid handle: must be non-negative"); + return; + } + }; + + let mut managers = lock_or_fail!(KEY_MANAGERS, ()); + if idx < managers.len() { + managers[idx] = None; + } +} + +/// Get the default payment address. +#[no_mangle] +pub extern "C" fn cw_pivx_get_default_address(handle: i64) -> *mut c_char { + let managers = lock_or_fail!(KEY_MANAGERS, ptr::null_mut()); + let manager = get_from_handle!(handle, managers, ptr::null_mut(), "key handle"); + + match manager.default_address() { + Ok(addr) => { + let encoded = manager.encode_payment_address(&addr); + CString::new(encoded) + .expect("Address encoding is valid: no null bytes") + .into_raw() + } + Err(e) => { + set_error(&format!("Failed to get address: {:?}", e)); + ptr::null_mut() + } + } +} + +/// Derive an address at a specific index. +#[no_mangle] +pub extern "C" fn cw_pivx_derive_address(handle: i64, index: u64) -> *mut c_char { + let managers = lock_or_fail!(KEY_MANAGERS, ptr::null_mut()); + let idx = handle as usize; + + if idx >= managers.len() { + set_error("Invalid handle"); + return ptr::null_mut(); + } + + let manager = match &managers[idx] { + Some(m) => m, + None => { + set_error("Handle disposed"); + return ptr::null_mut(); + } + }; + + let mut div_bytes = [0u8; 11]; + div_bytes[0..8].copy_from_slice(&(index as u64).to_le_bytes()); + let div_index = zcash_primitives::zip32::DiversifierIndex::from(div_bytes); + + match manager.derive_address(div_index) { + Ok(addr) => { + let encoded = manager.encode_payment_address(&addr); + CString::new(encoded) + .expect("Address encoding is valid: no null bytes") + .into_raw() + } + Err(e) => { + set_error(&format!("Failed to derive address: {:?}", e)); + ptr::null_mut() + } + } +} + +/// Get the full viewing key. +#[no_mangle] +pub extern "C" fn cw_pivx_get_viewing_key(handle: i64) -> *mut c_char { + let managers = lock_or_fail!(KEY_MANAGERS, ptr::null_mut()); + let idx = handle as usize; + + if idx >= managers.len() { + set_error("Invalid handle"); + return ptr::null_mut(); + } + + let manager = match &managers[idx] { + Some(m) => m, + None => { + set_error("Handle disposed"); + return ptr::null_mut(); + } + }; + + let encoded = manager.encode_full_viewing_key(); + CString::new(encoded) + .expect("FVK encoding is valid: no null bytes") + .into_raw() +} + +/// Validate a Sapling address. +#[no_mangle] +pub extern "C" fn cw_pivx_validate_address(address: *const c_char, is_testnet: u8) -> u8 { + if address.is_null() { + return 0; + } + + let address_str = unsafe { + match CStr::from_ptr(address).to_str() { + Ok(s) => s, + Err(_) => return 0, + } + }; + + let network = if is_testnet != 0 { + Network::Testnet + } else { + Network::Mainnet + }; + + if validate_address(address_str, network) { + 1 + } else { + 0 + } +} + +/// Initialize sync engine. +#[no_mangle] +pub extern "C" fn cw_pivx_init_sync_engine(_is_testnet: u8) -> i64 { + let mut states = lock_or_fail!(SYNC_STATES, -1); + let id = states.len() as i64; + states.push(Some(SyncState::new())); + id +} + +/// Dispose sync engine. +#[no_mangle] +pub extern "C" fn cw_pivx_dispose_sync_engine(handle: i64) { + let idx = handle as usize; + let mut states = lock_or_fail!(SYNC_STATES, ()); + if idx < states.len() { + states[idx] = None; + } +} + +/// Get the current sync height. +#[no_mangle] +pub extern "C" fn cw_pivx_get_sync_height(handle: i64) -> u32 { + let states = lock_or_fail!(SYNC_STATES, 0); + let idx = handle as usize; + + match states.get(idx).and_then(|s| s.as_ref()) { + Some(state) => state.sync_height(), + None => 0, + } +} + +/// Get the shielded balance. +#[no_mangle] +pub extern "C" fn cw_pivx_get_shielded_balance(handle: i64) -> u64 { + let states = lock_or_fail!(SYNC_STATES, 0); + let idx = handle as usize; + + match states.get(idx).and_then(|s| s.as_ref()) { + Some(state) => state.shielded_balance(), + None => 0, + } +} + +/// Get the number of unspent notes. +#[no_mangle] +pub extern "C" fn cw_pivx_get_unspent_note_count(handle: i64) -> usize { + let states = lock_or_fail!(SYNC_STATES, 0); + let idx = handle as usize; + + match states.get(idx).and_then(|s| s.as_ref()) { + Some(state) => state.unspent_notes().len(), + None => 0, + } +} + +/// Reset sync state. +#[no_mangle] +pub extern "C" fn cw_pivx_reset_sync(handle: i64) { + let mut states = lock_or_fail!(SYNC_STATES, ()); + let idx = handle as usize; + + if let Some(Some(state)) = states.get_mut(idx) { + *state = SyncState::new(); + } +} + +/// Try to decrypt a Sapling output and add to sync state if successful. +/// +/// This is the core function for detecting incoming shielded transactions. +/// It attempts trial decryption of a Sapling output using the wallet's +/// incoming viewing key. +/// +/// # Parameters +/// * `key_handle`: Handle from cw_pivx_init_keys +/// * `sync_handle`: Handle from cw_pivx_init_sync_engine +/// * `cmu`: Note commitment (32 bytes) +/// * `epk`: Ephemeral public key (32 bytes) +/// * `enc_ciphertext`: Encrypted ciphertext (580 bytes) +/// * `height`: Block height +/// * `tx_index`: Transaction index in block +/// * `output_index`: Output index in transaction +/// * `position`: Position in commitment tree +/// +/// # Returns +/// The note value in zatoshis if decryption succeeds, 0 otherwise. +#[no_mangle] +pub extern "C" fn cw_pivx_try_decrypt_output( + key_handle: i64, + sync_handle: i64, + cmu: *const u8, + epk: *const u8, + enc_ciphertext: *const u8, + height: u32, + tx_index: u32, + output_index: u32, + position: u64, +) -> u64 { + if cmu.is_null() || epk.is_null() || enc_ciphertext.is_null() { + set_error("Null pointer provided"); + return 0; + } + + let managers = lock_or_fail!(KEY_MANAGERS, 0); + let key_manager = match managers.get(key_handle as usize).and_then(|m| m.as_ref()) { + Some(m) => m, + None => { + set_error("Invalid key handle"); + return 0; + } + }; + + let cmu_bytes: [u8; 32] = match unsafe { bytes_from_ffi_ptr(cmu, "cmu") } { + Ok(bytes) => bytes, + Err(e) => { + set_error(&e); + return 0; + } + }; + + let cmu_extracted = match ExtractedNoteCommitment::from_bytes(&cmu_bytes).into_option() { + Some(c) => c, + None => { + set_error("Invalid note commitment (cmu)"); + return 0; // Invalid commitment: this IS an error, not just "not for us" + } + }; + + let epk_bytes: [u8; 32] = match unsafe { bytes_from_ffi_ptr(epk, "epk") } { + Ok(bytes) => bytes, + Err(e) => { + set_error(&e); + return 0; + } + }; + let ephemeral_key = EphemeralKeyBytes(epk_bytes); + + let enc_bytes: [u8; ENC_CIPHERTEXT_SIZE] = + match unsafe { bytes_from_ffi_ptr(enc_ciphertext, "enc_ciphertext") } { + Ok(bytes) => bytes, + Err(e) => { + set_error(&e); + return 0; + } + }; + + let output = TrialDecryptionOutput { + ephemeral_key, + cmu: cmu_extracted, + enc_ciphertext: enc_bytes, + }; + + let dfvk = key_manager.diversifiable_full_viewing_key(); + let ivk = dfvk.fvk().vk.ivk(); + let prepared_ivk = PreparedIncomingViewingKey::new(&ivk); + + // PIVX does not enforce ZIP-212 (unlike Zcash post-Canopy); librustpivx's + // zip212_enforcement() always returns Off. + let zip212 = Zip212Enforcement::Off; + + let result = try_sapling_note_decryption(&prepared_ivk, &output, zip212); + + match result { + Some((note, address, memo)) => { + let value = note.value().inner(); + let nf = note.nf(&dfvk.fvk().vk.nk, position); + let mut spendable_note = + SpendableNote::new(note, address, position, nf, height, tx_index, output_index); + // our send path writes the memo as raw utf8 with zero padding, so + // strip trailing zeros and read as utf8; an empty memo stays None. + let end = memo.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1); + if end > 0 { + spendable_note.memo = String::from_utf8(memo[..end].to_vec()).ok(); + } + + drop(managers); // release before acquiring SYNC_STATES + let mut states = lock_or_fail!(SYNC_STATES, 0); + if let Some(Some(state)) = states.get_mut(sync_handle as usize) { + let _ = state.add_note(spendable_note); + } + + value + } + None => { + // not for us, or decryption failed + 0 + } + } +} + +/// Check if a nullifier matches any of our notes and mark them spent. +/// +/// # Parameters +/// * `sync_handle`: Handle from cw_pivx_init_sync_engine +/// * `nullifier`: 32-byte nullifier to check +/// +/// # Returns +/// 1 if a note was marked spent, 0 otherwise. +#[no_mangle] +pub extern "C" fn cw_pivx_check_nullifier(sync_handle: i64, nullifier: *const u8) -> u8 { + if nullifier.is_null() { + return 0; + } + + let nf_bytes: [u8; 32] = unsafe { slice::from_raw_parts(nullifier, 32) } + .try_into() + .unwrap_or([0u8; 32]); + + let nf = match sapling::Nullifier::from_slice(&nf_bytes) { + Ok(n) => n, + Err(_) => return 0, + }; + + let mut states = lock_or_fail!(SYNC_STATES, 0); + if let Some(Some(state)) = states.get_mut(sync_handle as usize) { + if state.is_nullifier_spent(&nf) { + return 0; // Already known + } + state.add_spent_nullifier(nf); + return 1; + } + + 0 +} + +/// Update sync height after processing a block. +#[no_mangle] +pub extern "C" fn cw_pivx_set_sync_height(sync_handle: i64, height: u32) { + let mut states = lock_or_fail!(SYNC_STATES, ()); + if let Some(Some(state)) = states.get_mut(sync_handle as usize) { + state.set_sync_height(height); + } +} + +/// Estimate transaction fee. +/// Returns the estimated fee in zatoshis, or u64::MAX if overflow would occur. +#[no_mangle] +pub extern "C" fn cw_pivx_estimate_fee( + spends: usize, + outputs: usize, + t_inputs: usize, + t_outputs: usize, +) -> u64 { + // PIVX Core shielded relay policy: size * 10,000 zatoshis/kB * 100, rounded up. + const MIN_RELAY_FEE_PER_KB: u64 = 10_000; + const SHIELDED_FEE_FACTOR: u64 = 100; + const MIN_FEE: u64 = 10_000; + const SAPLING_SPEND_SIZE: u64 = 384; + const SAPLING_OUTPUT_SIZE: u64 = 948; + const TRANSPARENT_INPUT_SIZE: u64 = 148; + const TRANSPARENT_OUTPUT_SIZE: u64 = 34; + // Fixed non-count bytes; the four CompactSize vector-count prefixes are + // added per-vector below so the estimate stays an exact upper bound past + // 253 elements (below that this equals the old flat 85). + const SAPLING_FIXED_OVERHEAD_SIZE: u64 = 81; + + // Validate inputs are in reasonable range (prevent overflow attacks) + const MAX_INPUTS: usize = 10_000; + if spends > MAX_INPUTS + || outputs > MAX_INPUTS + || t_inputs > MAX_INPUTS + || t_outputs > MAX_INPUTS + { + set_error("Too many inputs/outputs for fee calculation"); + return u64::MAX; // Signal error with saturated value + } + + // The Sapling builder pads shielded outputs to at least MIN_SHIELDED_OUTPUTS + // with dummy outputs (protocol privacy rule, shared with PIVX Core), so the + // wire tx has that many even with fewer real outputs. + const MIN_SHIELDED_OUTPUTS: usize = 2; + let effective_outputs = outputs.max(MIN_SHIELDED_OUTPUTS); + + // CompactSize prefix length per vector: 1 byte below 253, 3 up to 65535. + let compact = |n: usize| -> u64 { + if n < 0xfd { + 1 + } else if n <= 0xffff { + 3 + } else { + 5 + } + }; + let overhead = SAPLING_FIXED_OVERHEAD_SIZE + + compact(spends) + + compact(effective_outputs) + + compact(t_inputs) + + compact(t_outputs); + + let size = overhead + .checked_add((spends as u64).saturating_mul(SAPLING_SPEND_SIZE)) + .and_then(|v| { + v.checked_add((effective_outputs as u64).saturating_mul(SAPLING_OUTPUT_SIZE)) + }) + .and_then(|v| v.checked_add((t_inputs as u64).saturating_mul(TRANSPARENT_INPUT_SIZE))) + .and_then(|v| v.checked_add((t_outputs as u64).saturating_mul(TRANSPARENT_OUTPUT_SIZE))); + + match size + .and_then(|s| s.checked_mul(MIN_RELAY_FEE_PER_KB)) + .and_then(|s| s.checked_mul(SHIELDED_FEE_FACTOR)) + { + Some(weighted_size) => { + let fee = weighted_size.saturating_add(999) / 1000; + fee.max(MIN_FEE) + } + None => { + set_error("Fee calculation overflow"); + u64::MAX + } + } +} + +/// Check if proving parameters are available. +#[no_mangle] +pub extern "C" fn cw_pivx_has_proving_params(path: *const c_char) -> u8 { + if path.is_null() { + return 0; + } + + let path_str = unsafe { + match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return 0, + } + }; + + if crate::prover::has_proving_params(path_str) { + 1 + } else { + 0 + } +} + +/// Initialize the Groth16 prover with the proving parameters. +/// +/// This loads the ~50MB proving parameter files into memory. +/// Should be called once before any transaction building. +/// +/// # Parameters +/// * `params_dir`: Path to directory containing sapling-spend.params and sapling-output.params +/// +/// # Returns +/// 0 on success, negative on error +#[no_mangle] +pub extern "C" fn cw_pivx_init_prover(params_dir: *const c_char) -> i32 { + if params_dir.is_null() { + set_error("Null params directory"); + return -1; + } + + let dir_str = unsafe { + match CStr::from_ptr(params_dir).to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid path encoding"); + return -1; + } + } + }; + + match crate::prover::init_prover(dir_str) { + Ok(()) => 0, + Err(e) => { + set_error(&format!("Failed to init prover: {}", e)); + -1 + } + } +} + +/// Check if the prover is initialized. +#[no_mangle] +pub extern "C" fn cw_pivx_is_prover_initialized() -> u8 { + if crate::prover::is_prover_initialized() { + 1 + } else { + 0 + } +} + +/// Free the prover and release memory (~50MB). +#[no_mangle] +pub extern "C" fn cw_pivx_dispose_prover() { + crate::prover::dispose_prover(); +} + +// serialize one note to the json shape the dart side stores/spends with. +fn spendable_note_to_json(note: &SpendableNote) -> serde_json::Value { + // BeforeZip212 rseed is an Fr scalar; AfterZip212 is raw [u8; 32]. + let rseed_bytes = match note.note.rseed() { + sapling::Rseed::BeforeZip212(fr_value) => hex::encode(fr_value.to_bytes()), + sapling::Rseed::AfterZip212(bytes) => hex::encode(bytes), + }; + let recipient = note.note.recipient(); + let addr_bytes = recipient.to_bytes(); + let diversifier_bytes = recipient.diversifier().0; + let cmu_bytes = note.note.cmu().to_bytes(); + let pk_d_bytes = recipient.pk_d().inner().to_bytes(); + // PIVX is pre-ZIP-212, so rcm == rseed. + let rcm_bytes = rseed_bytes.clone(); + + serde_json::json!({ + "value": note.value(), + "position": note.position, + "height": note.height, + "tx_index": note.tx_index, + "output_index": note.output_index, + "nullifier": hex::encode(note.nullifier.0), + "rseed": rseed_bytes, + "rcm": rcm_bytes, + "address": hex::encode(addr_bytes), + "diversifier": hex::encode(diversifier_bytes), + "pk_d": hex::encode(pk_d_bytes), + "cmu": hex::encode(cmu_bytes), + "memo": note.memo, + }) +} + +/// Get all spendable notes from the sync state as JSON. +/// +/// Returns a JSON array of note objects, each containing all data +/// needed for transaction building including the rseed and diversifier. +/// +/// # Parameters +/// * `sync_handle`: Handle from cw_pivx_init_sync_engine +/// +/// # Returns +/// JSON string with note data, or null on error. +/// Caller must free with cw_pivx_free_string. +#[no_mangle] +pub extern "C" fn cw_pivx_get_spendable_notes(sync_handle: i64) -> *mut c_char { + let states = lock_or_fail!(SYNC_STATES, ptr::null_mut()); + + let sync_state = match states.get(sync_handle as usize).and_then(|s| s.as_ref()) { + Some(s) => s, + None => { + set_error("Invalid sync handle"); + return ptr::null_mut(); + } + }; + + let notes_json: Vec = sync_state + .unspent_notes() + .iter() + .map(|note| spendable_note_to_json(note)) + .collect(); + + let json_str = serde_json::to_string(¬es_json).unwrap_or_else(|_| "[]".to_string()); + CString::new(json_str) + .expect("JSON string is valid: no null bytes") + .into_raw() +} + +/// Get the single unspent note at [position] as JSON (the one just decrypted), +/// so the scan loop doesn't re-serialize every note on each match (was O(K^2) +/// over a restore). Returns null if there's no unspent note there. +/// Caller must free with cw_pivx_free_string. +#[no_mangle] +pub extern "C" fn cw_pivx_get_note_at_position(sync_handle: i64, position: u64) -> *mut c_char { + let states = lock_or_fail!(SYNC_STATES, ptr::null_mut()); + let sync_state = match states.get(sync_handle as usize).and_then(|s| s.as_ref()) { + Some(s) => s, + None => { + set_error("Invalid sync handle"); + return ptr::null_mut(); + } + }; + + match sync_state + .unspent_notes() + .into_iter() + .find(|n| n.position == position) + { + Some(note) => { + let json_str = + serde_json::to_string(&spendable_note_to_json(note)).unwrap_or_default(); + match CString::new(json_str) { + Ok(c) => c.into_raw(), + Err(_) => ptr::null_mut(), + } + } + None => ptr::null_mut(), + } +} + +/// Restore a note from JSON data. +/// +/// This allows restoring notes from persistent storage after app restart. +/// The JSON should contain the same fields returned by cw_pivx_get_spendable_notes. +/// +/// # Parameters +/// * `key_handle`: Handle from cw_pivx_init_keys +/// * `sync_handle`: Handle from cw_pivx_init_sync_engine +/// * `note_json`: JSON string with note data +/// +/// # Returns +/// 1 on success, 0 on failure +#[no_mangle] +pub extern "C" fn cw_pivx_restore_note( + _key_handle: i64, + sync_handle: i64, + note_json: *const c_char, +) -> i32 { + if note_json.is_null() { + set_error("Null note JSON"); + return 0; + } + + let json_str = match unsafe { CStr::from_ptr(note_json).to_str() } { + Ok(s) => s, + Err(_) => { + set_error("Invalid UTF-8 in note JSON"); + return 0; + } + }; + + let note_data: serde_json::Value = match serde_json::from_str(json_str) { + Ok(v) => v, + Err(e) => { + set_error(&format!("Invalid JSON: {}", e)); + return 0; + } + }; + + let value = note_data["value"].as_u64().unwrap_or(0); + let position = note_data["position"].as_u64().unwrap_or(0); + let height = note_data["height"].as_u64().unwrap_or(0) as u32; + let tx_index = note_data["tx_index"].as_u64().unwrap_or(0) as u32; + let output_index = note_data["output_index"].as_u64().unwrap_or(0) as u32; + + let rseed_hex = note_data["rseed"].as_str().unwrap_or(""); + let address_hex = note_data["address"].as_str().unwrap_or(""); + let nullifier_hex = note_data["nullifier"].as_str().unwrap_or(""); + + // rseed is a 32-byte Fr scalar (BeforeZip212). + let rseed_bytes: [u8; 32] = match hex::decode(rseed_hex) { + Ok(bytes) if bytes.len() == 32 => bytes + .try_into() + .expect("Length checked: rseed is exactly 32 bytes"), + _ => { + set_error("Invalid rseed"); + return 0; + } + }; + + // Address is 43 bytes: 11-byte diversifier + 32-byte pk_d. + let address_bytes: [u8; 43] = match hex::decode(address_hex) { + Ok(bytes) if bytes.len() == 43 => bytes + .try_into() + .expect("Length checked: address is exactly 43 bytes"), + _ => { + set_error(&format!( + "Invalid address: expected 43 bytes, got {} from '{}'", + hex::decode(address_hex).map(|b| b.len()).unwrap_or(0), + address_hex + )); + return 0; + } + }; + + let nullifier_bytes: [u8; 32] = match hex::decode(nullifier_hex) { + Ok(bytes) if bytes.len() == 32 => bytes + .try_into() + .expect("Length checked: nullifier is exactly 32 bytes"), + _ => { + set_error("Invalid nullifier"); + return 0; + } + }; + + use sapling::{value::NoteValue, PaymentAddress, Rseed}; + + let address = match PaymentAddress::from_bytes(&address_bytes) { + Some(a) => a, + None => { + set_error("Invalid payment address bytes"); + return 0; + } + }; + + // PIVX uses BeforeZip212. + let rseed_fr = match jubjub::Fr::from_bytes(&rseed_bytes).into_option() { + Some(fr) => fr, + None => { + set_error("Invalid rseed Fr"); + return 0; + } + }; + let rseed = Rseed::BeforeZip212(rseed_fr); + + let note = sapling::Note::from_parts(address, NoteValue::from_raw(value), rseed); + let nullifier = sapling::Nullifier(nullifier_bytes); + + let spendable_note = SpendableNote::new( + note, + address, + position, + nullifier, + height, + tx_index, + output_index, + ); + + let mut states = lock_or_fail!(SYNC_STATES, -1); + if let Some(Some(state)) = states.get_mut(sync_handle as usize) { + let _ = state.add_note(spendable_note); + 1 + } else { + set_error("Invalid sync handle"); + 0 + } +} + +/// Build a transparent-to-shielded (t-to-z, shield) transaction. +/// +/// `utxos_json` is an array of objects with `txid` (display hex), `vout`, +/// `value`, `script_pubkey` (hex, P2PKH) and `private_key` (32-byte hex). +/// `change` of zero means no transparent change output; otherwise +/// `change_address` receives it. Amounts must balance exactly: +/// sum(utxos) = amount + change + fee. +#[no_mangle] +pub extern "C" fn cw_pivx_build_shield_tx( + key_handle: i64, + utxos_json: *const c_char, + to_address: *const c_char, + amount: u64, + memo: *const c_char, + fee: u64, + change_address: *const c_char, + change: u64, +) -> FFIBuffer { + let empty_result = FFIBuffer { + data: ptr::null_mut(), + len: 0, + }; + + if utxos_json.is_null() || to_address.is_null() { + set_error("Null parameter provided"); + return empty_result; + } + if let Err(e) = validate_shielded_amount(amount, "amount") { + set_error(&e); + return empty_result; + } + if let Err(e) = validate_fee(fee) { + set_error(&e); + return empty_result; + } + if !crate::prover::is_prover_initialized() { + set_error("Prover not initialized. Call cw_pivx_init_prover first."); + return empty_result; + } + + let utxos_str = unsafe { + match CStr::from_ptr(utxos_json).to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid UTXO JSON encoding"); + return empty_result; + } + } + }; + let to_str = unsafe { + match CStr::from_ptr(to_address).to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid address encoding"); + return empty_result; + } + } + }; + let memo_str = if memo.is_null() { + None + } else { + unsafe { + match CStr::from_ptr(memo).to_str() { + Ok(s) if !s.is_empty() => Some(s.to_string()), + _ => None, + } + } + }; + if let Err(e) = validate_memo(memo_str.as_deref()) { + set_error(&e); + return empty_result; + } + if let Err(e) = validate_string_length(utxos_str, 1_000_000, "utxos_json") { + set_error(&e); + return empty_result; + } + if let Err(e) = validate_string_length(to_str, 1000, "to_address") { + set_error(&e); + return empty_result; + } + + let managers = lock_or_fail!(KEY_MANAGERS, empty_result); + let key_manager = match managers.get(key_handle as usize).and_then(|m| m.as_ref()) { + Some(m) => m, + None => { + set_error("Invalid key handle"); + return empty_result; + } + }; + let testnet = key_manager.network() == crate::types::Network::Testnet; + + // The shield destination must be a Sapling payment address. + let recipient = match key_manager.decode_payment_address(to_str) { + Ok(addr) => addr, + Err(e) => { + set_error(&format!("Invalid shield destination address: {}", e)); + return empty_result; + } + }; + + // Parse and validate the UTXO inputs and their signing keys. + let utxos_data: Vec = + match serde_json::from_str(utxos_str) { + Ok(u) => u, + Err(e) => { + set_error(&format!("Failed to parse UTXO JSON: {}", e)); + return empty_result; + } + }; + if utxos_data.is_empty() { + set_error("No UTXOs provided"); + return empty_result; + } + let mut inputs = Vec::with_capacity(utxos_data.len()); + for (idx, utxo) in utxos_data.iter().enumerate() { + match crate::transaction::TransparentInput::from_parts( + &utxo.txid, + utxo.vout, + utxo.value, + &utxo.script_pubkey, + &utxo.private_key, + ) { + Ok(input) => inputs.push(input), + Err(e) => { + set_error(&format!("Invalid UTXO {}: {}", idx, e)); + return empty_result; + } + } + } + + // Optional transparent change. + let transparent_change = if change == 0 { + None + } else { + let change_str = if change_address.is_null() { + None + } else { + unsafe { CStr::from_ptr(change_address).to_str().ok() } + }; + let change_str = match change_str { + Some(s) if !s.is_empty() => s, + _ => { + set_error("Change amount requires a change address"); + return empty_result; + } + }; + match crate::transaction::TransparentOutput::to_address(change_str, change, testnet) { + Ok(output) => Some(output), + Err(e) => { + set_error(&format!("Invalid change address: {}", e)); + return empty_result; + } + } + }; + + let memo_bytes: Option<[u8; 512]> = memo_str.as_ref().map(|m| { + let mut bytes = [0u8; 512]; + let m_bytes = m.as_bytes(); + let len = m_bytes.len().min(512); + bytes[..len].copy_from_slice(&m_bytes[..len]); + bytes + }); + let outputs = vec![(recipient, amount, memo_bytes)]; + + let tx_builder = crate::transaction::TransactionBuilder::new( + key_manager.extended_spending_key().clone(), + key_manager.diversifiable_full_viewing_key().clone(), + testnet, + ); + + match tx_builder.build_shield_transaction(inputs, outputs, transparent_change, fee) { + Ok(built_tx) => { + let mut txid_hex = hex::encode(built_tx.txid); + let mut tx_hex = hex::encode(&built_tx.raw_tx); + let result_str = format!( + r#"{{"status":"success","txid":"{}","tx_hex":"{}","fee":{}}}"#, + txid_hex, tx_hex, built_tx.fee + ); + let buffer = match ffi_buffer_from_string(result_str) { + Some(buffer) => buffer, + None => { + txid_hex.zeroize(); + tx_hex.zeroize(); + set_error("Memory allocation failed"); + return empty_result; + } + }; + txid_hex.zeroize(); + tx_hex.zeroize(); + buffer + } + Err(e) => { + let error_json = serde_json::to_string(&format!("{}", e)) + .unwrap_or_else(|_| "\"shield transaction build failed\"".to_string()); + let result_str = format!( + r#"{{"status":"error","error":{}}}"#, + error_json + ); + match ffi_buffer_from_string(result_str) { + Some(buffer) => buffer, + None => { + set_error(&format!("Shield transaction build failed: {}", e)); + empty_result + } + } + } + } +} + +#[no_mangle] +pub extern "C" fn cw_pivx_build_shielded_tx( + key_handle: i64, + notes_json: *const c_char, + to_address: *const c_char, + amount: u64, + memo: *const c_char, + fee: u64, + anchor_hex: *const c_char, +) -> FFIBuffer { + let empty_result = FFIBuffer { + data: ptr::null_mut(), + len: 0, + }; + + if notes_json.is_null() || to_address.is_null() || anchor_hex.is_null() { + set_error("Null parameter provided"); + return empty_result; + } + + // Validate fee range; the amount dust check is destination-dependent + // (shielded vs transparent) and happens after address parsing below. + if let Err(e) = validate_fee(fee) { + set_error(&e); + return empty_result; + } + + if !crate::prover::is_prover_initialized() { + set_error("Prover not initialized. Call cw_pivx_init_prover first."); + return empty_result; + } + + let notes_str = unsafe { + match CStr::from_ptr(notes_json).to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid notes JSON encoding"); + return empty_result; + } + } + }; + + let to_str = unsafe { + match CStr::from_ptr(to_address).to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid address encoding"); + return empty_result; + } + } + }; + + let anchor_str = unsafe { + match CStr::from_ptr(anchor_hex).to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid anchor encoding"); + return empty_result; + } + } + }; + + let memo_str = if memo.is_null() { + None + } else { + unsafe { + match CStr::from_ptr(memo).to_str() { + Ok(s) if !s.is_empty() => Some(s.to_string()), + _ => None, + } + } + }; + + if let Err(e) = validate_memo(memo_str.as_deref()) { + set_error(&e); + return empty_result; + } + + // Length caps guard against DoS via huge inputs. + if let Err(e) = validate_string_length(notes_str, 1_000_000, "notes_json") { + set_error(&e); + return empty_result; + } + if let Err(e) = validate_string_length(to_str, 1000, "to_address") { + set_error(&e); + return empty_result; + } + if let Err(e) = validate_string_length(anchor_str, 100, "anchor_hex") { + set_error(&e); + return empty_result; + } + + let managers = lock_or_fail!(KEY_MANAGERS, empty_result); + let key_manager = match managers.get(key_handle as usize).and_then(|m| m.as_ref()) { + Some(m) => m, + None => { + set_error("Invalid key handle"); + return empty_result; + } + }; + + let notes_data: Vec = match serde_json::from_str(notes_str) { + Ok(n) => n, + Err(e) => { + set_error(&format!("Failed to parse notes JSON: {}", e)); + return empty_result; + } + }; + + if notes_data.is_empty() { + set_error("No notes provided"); + return empty_result; + } + + let total_input: u64 = notes_data.iter().map(|n| n.value).sum(); + if total_input < amount + fee { + set_error(&format!( + "Insufficient funds: have {} zatoshis, need {} + {} fee", + total_input, amount, fee + )); + return empty_result; + } + + // Parse destination address: a Sapling payment address selects the + // z-to-z route, a PIVX base58 transparent address selects z-to-t + // (deshield) with a transparent vout and shielded change. + let testnet = key_manager.network() == crate::types::Network::Testnet; + let mut shielded_recipient = None; + let mut transparent_script = None; + match key_manager.decode_payment_address(to_str) { + Ok(addr) => shielded_recipient = Some(addr), + Err(shielded_error) => { + match crate::transaction::script_pubkey_for_transparent_address(to_str, testnet) { + Ok(script) => transparent_script = Some(script), + Err(_) => { + set_error(&format!("Invalid recipient address: {}", shielded_error)); + return empty_result; + } + } + } + } + + if transparent_script.is_some() { + if amount < crate::transaction::TRANSPARENT_DUST_THRESHOLD { + set_error(&format!( + "amount is below transparent dust threshold ({} zatoshis)", + crate::transaction::TRANSPARENT_DUST_THRESHOLD + )); + return empty_result; + } + if memo_str.is_some() { + set_error("Memo is not supported for transparent destinations"); + return empty_result; + } + } else if let Err(e) = validate_shielded_amount(amount, "amount") { + set_error(&e); + return empty_result; + } + + let anchor_bytes: [u8; 32] = match hex::decode(anchor_str) { + Ok(bytes) if bytes.len() == 32 => bytes + .try_into() + .expect("Length checked: anchor is exactly 32 bytes"), + _ => { + set_error("Invalid anchor: must be 32-byte hex"); + return empty_result; + } + }; + + let anchor = match sapling::Anchor::from_bytes(anchor_bytes).into_option() { + Some(a) => a, + None => { + set_error("Invalid anchor bytes"); + return empty_result; + } + }; + + let mut spendable_notes = Vec::with_capacity(notes_data.len()); + let mut merkle_paths = Vec::with_capacity(notes_data.len()); + + for (idx, note_data) in notes_data.iter().enumerate() { + let (note, address) = match crate::notes::note_from_parts( + ¬e_data.diversifier, + ¬e_data.pk_d, + note_data.value, + ¬e_data.rseed, + ) { + Ok(n) => n, + Err(e) => { + set_error(&format!("Failed to reconstruct note {}: {}", idx, e)); + return empty_result; + } + }; + + if let Some(expected_cmu) = note_data.cmu.as_ref() { + let expected_cmu_bytes: [u8; 32] = match hex::decode(expected_cmu) { + Ok(bytes) if bytes.len() == 32 => bytes + .try_into() + .expect("Length checked: cmu is exactly 32 bytes"), + _ => { + set_error(&format!("Invalid cmu for note {}", idx)); + return empty_result; + } + }; + let actual_cmu = note.cmu().to_bytes(); + if actual_cmu != expected_cmu_bytes { + set_error(&format!( + "Reconstructed note commitment mismatch for note {}", + idx + )); + return empty_result; + } + } + + let position = note_data.witness_position; + + // Parse merkle path from witness before proving so we can verify the + // witness is actually anchored to the selected root. + let path = match crate::notes::parse_merkle_path(¬e_data.witness, position) { + Ok(p) => p, + Err(e) => { + set_error(&format!("Failed to parse witness for note {}: {}", idx, e)); + return empty_result; + } + }; + + let witness_root = sapling::Anchor::from(path.root(Node::from_cmu(¬e.cmu()))); + if witness_root.to_bytes() != anchor.to_bytes() { + set_error(&format!( + "Witness root mismatch for note {}: witness root does not match spend anchor", + idx + )); + return empty_result; + } + + let nullifier_bytes: [u8; 32] = match hex::decode(¬e_data.nullifier) { + Ok(bytes) if bytes.len() == 32 => bytes + .try_into() + .expect("Length checked: nullifier is exactly 32 bytes"), + _ => { + set_error(&format!("Invalid nullifier for note {}", idx)); + return empty_result; + } + }; + let nullifier = sapling::Nullifier(nullifier_bytes); + let expected_nullifier = note.nf( + &key_manager + .diversifiable_full_viewing_key() + .fvk() + .vk + .nk, + position, + ); + if expected_nullifier.0 != nullifier.0 { + set_error(&format!("Nullifier mismatch for note {}", idx)); + return empty_result; + } + + let spendable = crate::notes::SpendableNote::new( + note, address, position, // witness_position from the ElectrumX response + nullifier, 0, // height: not needed for spending + 0, // tx_index + 0, // output_index + ); + spendable_notes.push(spendable); + + merkle_paths.push(path); + } + + let memo_bytes: Option<[u8; 512]> = memo_str.as_ref().map(|m| { + let mut bytes = [0u8; 512]; + let m_bytes = m.as_bytes(); + let len = m_bytes.len().min(512); + bytes[..len].copy_from_slice(&m_bytes[..len]); + bytes + }); + + let (outputs, transparent_outputs) = match (shielded_recipient, transparent_script) { + (Some(recipient), _) => (vec![(recipient, amount, memo_bytes)], Vec::new()), + (None, Some(script_pubkey)) => ( + Vec::new(), + vec![crate::transaction::TransparentOutput { + value: amount, + script_pubkey, + }], + ), + (None, None) => { + set_error("Invalid recipient address"); + return empty_result; + } + }; + + let tx_builder = crate::transaction::TransactionBuilder::new( + key_manager.extended_spending_key().clone(), + key_manager.diversifiable_full_viewing_key().clone(), + testnet, + ); + + match tx_builder.build_route_transaction( + spendable_notes, + merkle_paths, + anchor, + outputs, + transparent_outputs, + fee, + ) { + Ok(built_tx) => { + let mut txid_hex = hex::encode(built_tx.txid); + let mut tx_hex = hex::encode(&built_tx.raw_tx); + let result_str = format!( + r#"{{"status":"success","txid":"{}","tx_hex":"{}","fee":{}}}"#, + txid_hex, tx_hex, built_tx.fee + ); + + let buffer = match ffi_buffer_from_string(result_str) { + Some(buffer) => buffer, + None => { + txid_hex.zeroize(); + tx_hex.zeroize(); + set_error("Memory allocation failed"); + return empty_result; + } + }; + + txid_hex.zeroize(); + tx_hex.zeroize(); + buffer + } + Err(e) => { + // Return error details as JSON so caller can understand what happened + let mut error_message = format!("{}", e); + let mut error_json = serde_json::to_string(&error_message) + .unwrap_or_else(|_| "\"transaction build failed\"".to_string()); + let result_str = format!( + r#"{{"status":"error","error":{},"notes_count":{},"total_input":{},"amount":{},"fee":{}}}"#, + error_json, + notes_data.len(), + total_input, + amount, + fee + ); + + let buffer = match ffi_buffer_from_string(result_str) { + Some(buffer) => buffer, + None => { + set_error(&format!("Transaction build failed: {}", e)); + error_message.zeroize(); + error_json.zeroize(); + return empty_result; + } + }; + + error_message.zeroize(); + error_json.zeroize(); + buffer + } + } +} + +/// Verify that a server-supplied witness recomputes to the expected anchor. +/// +/// * `witness_hex`: 32 sibling hashes as hex (2048 hex chars), the same +/// serialization `cw_pivx_build_shielded_tx` parses into a spend path. +/// * `cmu_hex`: 32-byte note commitment as hex. +/// * `anchor_hex`: 32-byte expected anchor (Merkle root) as hex. +/// * `position`: Position of the note in the commitment tree. +/// +/// Returns 1 when the locally recomputed root equals the anchor, 0 on a +/// clean mismatch, and -1 on parse or other errors (see +/// `cw_pivx_get_last_error`). +#[no_mangle] +pub extern "C" fn pivx_sapling_verify_witness_root( + witness_hex: *const c_char, + cmu_hex: *const c_char, + anchor_hex: *const c_char, + position: u64, +) -> i32 { + if witness_hex.is_null() || cmu_hex.is_null() || anchor_hex.is_null() { + set_error("Null pointer passed to verify_witness_root"); + return -1; + } + + let witness_str = match unsafe { CStr::from_ptr(witness_hex) }.to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid witness encoding"); + return -1; + } + }; + let cmu_str = match unsafe { CStr::from_ptr(cmu_hex) }.to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid cmu encoding"); + return -1; + } + }; + let anchor_str = match unsafe { CStr::from_ptr(anchor_hex) }.to_str() { + Ok(s) => s, + Err(_) => { + set_error("Invalid anchor encoding"); + return -1; + } + }; + + if let Err(e) = validate_string_length(witness_str, 4096, "witness_hex") { + set_error(&e); + return -1; + } + + let cmu_bytes: [u8; 32] = match hex::decode(cmu_str) { + Ok(bytes) if bytes.len() == 32 => bytes + .try_into() + .expect("Length checked: cmu is exactly 32 bytes"), + _ => { + set_error("Invalid cmu: must be 32-byte hex"); + return -1; + } + }; + let anchor_bytes: [u8; 32] = match hex::decode(anchor_str) { + Ok(bytes) if bytes.len() == 32 => bytes + .try_into() + .expect("Length checked: anchor is exactly 32 bytes"), + _ => { + set_error("Invalid anchor: must be 32-byte hex"); + return -1; + } + }; + + match crate::notes::verify_witness_root(witness_str, position, cmu_bytes, anchor_bytes) { + Ok(true) => 1, + Ok(false) => 0, + Err(e) => { + set_error(&format!("Witness root verification failed: {}", e)); + -1 + } + } +} + +#[no_mangle] +pub extern "C" fn pivx_sapling_init() -> i32 { + 0 // Success +} + +#[no_mangle] +pub extern "C" fn pivx_sapling_create_from_seed( + seed: *const u8, + seed_len: usize, + is_testnet: i32, + session_id: *mut i32, +) -> i32 { + let handle = cw_pivx_init_keys(seed, seed_len, is_testnet as u8); + if handle < 0 { + -1 + } else { + unsafe { *session_id = handle as i32 }; + 0 + } +} + +#[no_mangle] +pub extern "C" fn pivx_sapling_destroy(session_id: i32) -> i32 { + cw_pivx_dispose_keys(session_id as i64); + 0 +} + +#[no_mangle] +pub extern "C" fn pivx_sapling_get_balance(session_id: i32) -> i64 { + cw_pivx_get_shielded_balance(session_id as i64) as i64 +} + +#[no_mangle] +pub extern "C" fn pivx_sapling_get_sync_height(session_id: i32) -> i32 { + cw_pivx_get_sync_height(session_id as i64) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zero_ffi_allocation_overwrites_bytes() { + let mut bytes = vec![1u8, 2, 3, 4]; + + unsafe { + zero_ffi_allocation(bytes.as_mut_ptr(), bytes.len()); + } + + assert_eq!(bytes, vec![0u8; 4]); + } + + #[test] + fn test_ffi_buffer_from_string_copies_bytes() { + let buffer = ffi_buffer_from_string("ffi-json".to_string()).unwrap(); + assert!(!buffer.data.is_null()); + assert_eq!(buffer.len, 8); + + let bytes = unsafe { slice::from_raw_parts(buffer.data, buffer.len) }; + assert_eq!(bytes, b"ffi-json"); + + cw_pivx_free_buffer(buffer); + } + + #[test] + fn test_ffi_init_keys() { + let seed = [0u8; 64]; + + let handle = cw_pivx_init_keys(seed.as_ptr(), seed.len(), 0); + assert!(handle >= 0); + + cw_pivx_dispose_keys(handle); + } + + #[test] + fn test_ffi_get_address() { + let seed = [1u8; 64]; + + let handle = cw_pivx_init_keys(seed.as_ptr(), seed.len(), 0); + assert!(handle >= 0); + + let address_ptr = cw_pivx_get_default_address(handle); + assert!(!address_ptr.is_null()); + + let address = unsafe { CStr::from_ptr(address_ptr) }.to_str().unwrap(); + + assert!(address.starts_with("ps")); + + cw_pivx_free_string(address_ptr); + cw_pivx_dispose_keys(handle); + } + + #[test] + fn test_ffi_validate_address() { + let valid = "ps1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqf0vjel"; + let valid_ptr = CString::new(valid).expect("Test string is valid: no null bytes"); + + // This will fail validation because it's a dummy address, but the FFI call should work + let _result = cw_pivx_validate_address(valid_ptr.as_ptr(), 0); + // Address validation is tested in keys.rs + } + + #[test] + fn test_ffi_verify_witness_root() { + use sapling::note::ExtractedNoteCommitment; + use sapling::Anchor; + + // 32 canonical sibling nodes (value 1) and a canonical cmu (value 2). + let mut sibling = [0u8; 32]; + sibling[0] = 1; + let witness_hex = hex::encode(sibling).repeat(32); + let mut cmu = [0u8; 32]; + cmu[0] = 2; + + let path = crate::notes::parse_merkle_path(&witness_hex, 3).unwrap(); + let cmu_parsed = ExtractedNoteCommitment::from_bytes(&cmu) + .into_option() + .unwrap(); + let anchor = Anchor::from(path.root(Node::from_cmu(&cmu_parsed))).to_bytes(); + + let witness_c = CString::new(witness_hex.clone()).unwrap(); + let cmu_c = CString::new(hex::encode(cmu)).unwrap(); + let anchor_c = CString::new(hex::encode(anchor)).unwrap(); + + // Matching witness verifies. + assert_eq!( + pivx_sapling_verify_witness_root( + witness_c.as_ptr(), + cmu_c.as_ptr(), + anchor_c.as_ptr(), + 3 + ), + 1 + ); + + // Tampered sibling (value 3, still canonical) is a clean mismatch. + let mut tampered = witness_hex; + tampered.replace_range(0..2, "03"); + let tampered_c = CString::new(tampered).unwrap(); + assert_eq!( + pivx_sapling_verify_witness_root( + tampered_c.as_ptr(), + cmu_c.as_ptr(), + anchor_c.as_ptr(), + 3 + ), + 0 + ); + + // Wrong position is a clean mismatch. + assert_eq!( + pivx_sapling_verify_witness_root( + witness_c.as_ptr(), + cmu_c.as_ptr(), + anchor_c.as_ptr(), + 4 + ), + 0 + ); + + // Malformed inputs are errors, not mismatches. + let bad_hex = CString::new("zz").unwrap(); + assert_eq!( + pivx_sapling_verify_witness_root( + bad_hex.as_ptr(), + cmu_c.as_ptr(), + anchor_c.as_ptr(), + 3 + ), + -1 + ); + assert_eq!( + pivx_sapling_verify_witness_root( + witness_c.as_ptr(), + bad_hex.as_ptr(), + anchor_c.as_ptr(), + 3 + ), + -1 + ); + assert_eq!( + pivx_sapling_verify_witness_root( + witness_c.as_ptr(), + cmu_c.as_ptr(), + std::ptr::null(), + 3 + ), + -1 + ); + } + + // Guards the v1 display-order receive path: a display-order node sends cmu + // (and epk) byte-reversed, and try_sapling_note_decryption checks the + // decrypted note's commitment against the cmu it was handed. Forwarding the + // reversed bytes un-reversed yields a different commitment, so the note + // reads as "not ours" and is silently missed. The Dart receive path reverses + // cmu and epk before this boundary (sapling_factories.dart output loop). + #[test] + fn display_order_cmu_must_be_reversed_for_trial_decryption() { + use sapling::note::ExtractedNoteCommitment; + use sapling::value::NoteValue; + use sapling::{Note, Rseed}; + + let manager = SaplingKeyManager::from_seed(&[7u8; 64], Network::Mainnet).unwrap(); + let address = manager.default_address().unwrap(); + let mut rcm_bytes = [0u8; 32]; + rcm_bytes[0] = 3; + let rcm = jubjub::Fr::from_bytes(&rcm_bytes).into_option().unwrap(); + let note = Note::from_parts(address, NoteValue::from_raw(100_000), Rseed::BeforeZip212(rcm)); + + let serialization = note.cmu().to_bytes(); + + // Serialization (little-endian) order round-trips to the true commitment. + assert_eq!( + ExtractedNoteCommitment::from_bytes(&serialization) + .into_option() + .unwrap() + .to_bytes(), + serialization + ); + + // Display (big-endian) order, what an un-reversed v1 receive would use, + // does not decode to the note's commitment, so decryption would miss it. + let mut display = serialization; + display.reverse(); + let decoded = ExtractedNoteCommitment::from_bytes(&display).into_option(); + assert!( + decoded.map_or(true, |c| c.to_bytes() != serialization), + "reversed (display-order) cmu must not decode to the note's true commitment" + ); + } + + #[test] + fn test_ffi_estimate_fee() { + let fee = cw_pivx_estimate_fee(2, 2, 1, 1); + assert_eq!(fee, 2_931_000); + } + + #[test] + fn estimate_fee_grows_with_compact_size_prefix_past_253_spends() { + // Crossing 253 spends grows the CompactSize count prefix from 1 to 3 + // bytes: +1 spend (384) + 2, at the shielded rate = (384+2)*1000. + let at_252 = cw_pivx_estimate_fee(252, 1, 0, 0); + let at_253 = cw_pivx_estimate_fee(253, 1, 0, 0); + assert_eq!(at_253 - at_252, 386_000); + } + + #[test] + fn test_ffi_sync_engine() { + let handle = cw_pivx_init_sync_engine(0); + assert!(handle >= 0); + + let height = cw_pivx_get_sync_height(handle); + assert_eq!(height, 0); // Fresh sync state + + let balance = cw_pivx_get_shielded_balance(handle); + assert_eq!(balance, 0); + + let count = cw_pivx_get_unspent_note_count(handle); + assert_eq!(count, 0); + + cw_pivx_dispose_sync_engine(handle); + } +} diff --git a/cw_pivx/rust/src/keys.rs b/cw_pivx/rust/src/keys.rs new file mode 100644 index 0000000000..6161ed7caa --- /dev/null +++ b/cw_pivx/rust/src/keys.rs @@ -0,0 +1,294 @@ +//! PIVX Sapling key management. +//! +//! Implements ZIP-32 HD key derivation for Sapling shielded addresses. +//! Uses PIVX-specific HRPs (Human Readable Parts) for address encoding. +//! +//! # Security Note +//! +//! This module handles cryptographic secrets (spending keys) that must be +//! securely zeroed from memory when no longer needed. The SaplingKeyManager +//! implements Drop to ensure secrets are cleared. + +use bech32::{FromBase32, ToBase32, Variant}; +use sapling::{ + zip32::{DiversifiableFullViewingKey, ExtendedSpendingKey}, + PaymentAddress, +}; +use zcash_primitives::zip32::{ChildIndex, DiversifierIndex}; + +use crate::error::SaplingError; +use crate::types::Network; + +pub type SaplingResult = Result; + +/// Bech32 human readable parts for PIVX Sapling. +pub mod hrp { + pub const PAYMENT_ADDRESS_MAINNET: &str = "ps"; + pub const PAYMENT_ADDRESS_TESTNET: &str = "ptestsapling"; + + pub const FULL_VIEWING_KEY_MAINNET: &str = "pviews"; + pub const FULL_VIEWING_KEY_TESTNET: &str = "pviewtestsapling"; + + pub const EXTENDED_SPENDING_KEY_MAINNET: &str = "p-secret-extended-key-main"; + pub const EXTENDED_SPENDING_KEY_TESTNET: &str = "p-secret-extended-key-test"; +} + +pub struct SaplingKeyManager { + extended_spending_key: ExtendedSpendingKey, + dfvk: DiversifiableFullViewingKey, + diversifier_index: DiversifierIndex, + network: Network, +} + +impl SaplingKeyManager { + pub fn from_seed(seed: &[u8], network: Network) -> SaplingResult { + if seed.len() < 32 { + return Err(SaplingError::InvalidSeed); + } + + let master = ExtendedSpendingKey::master(seed); + + // PIVX Sapling path m/32'/119'/account', account 0. + let account_path = [ + ChildIndex::hardened(32), // Purpose: Sapling + ChildIndex::hardened(119), // Coin type: PIVX (SLIP-44) + ChildIndex::hardened(0), // Account 0 + ]; + + let extended_spending_key = ExtendedSpendingKey::from_path(&master, &account_path); + let dfvk = extended_spending_key.to_diversifiable_full_viewing_key(); + + Ok(Self { + extended_spending_key, + dfvk, + diversifier_index: DiversifierIndex::new(), + network, + }) + } + + pub fn extended_spending_key(&self) -> &ExtendedSpendingKey { + &self.extended_spending_key + } + + pub fn diversifiable_full_viewing_key(&self) -> &DiversifiableFullViewingKey { + &self.dfvk + } + + pub fn derive_address( + &self, + diversifier_index: DiversifierIndex, + ) -> SaplingResult { + self.dfvk + .address(diversifier_index) + .ok_or(SaplingError::InvalidDiversifier) + } + + pub fn default_address(&self) -> SaplingResult { + let (_, addr) = self.dfvk.default_address(); + Ok(addr) + } + + pub fn next_address(&mut self) -> SaplingResult { + let (new_index, addr) = self + .dfvk + .find_address(self.diversifier_index) + .ok_or(SaplingError::InvalidDiversifier)?; + + self.diversifier_index = new_index; + self.diversifier_index + .increment() + .map_err(|_| SaplingError::InvalidDiversifier)?; + + Ok(addr) + } + + pub fn encode_payment_address(&self, address: &PaymentAddress) -> String { + let hrp = match self.network { + Network::Mainnet => hrp::PAYMENT_ADDRESS_MAINNET, + Network::Testnet => hrp::PAYMENT_ADDRESS_TESTNET, + }; + + encode_payment_address(hrp, address) + } + + pub fn decode_payment_address(&self, encoded: &str) -> SaplingResult { + let expected_hrp = match self.network { + Network::Mainnet => hrp::PAYMENT_ADDRESS_MAINNET, + Network::Testnet => hrp::PAYMENT_ADDRESS_TESTNET, + }; + + decode_payment_address(expected_hrp, encoded) + } + + pub fn encode_full_viewing_key(&self) -> String { + let hrp = match self.network { + Network::Mainnet => hrp::FULL_VIEWING_KEY_MAINNET, + Network::Testnet => hrp::FULL_VIEWING_KEY_TESTNET, + }; + + let fvk = self.dfvk.to_bytes(); + bech32::encode(hrp, fvk.to_base32(), Variant::Bech32).expect("FVK encoding should not fail") + } + + pub fn is_our_address(&self, address: &PaymentAddress) -> bool { + let mut idx = DiversifierIndex::new(); + for _ in 0..1000 { + if let Some((_, derived)) = self.dfvk.find_address(idx) { + if derived == *address { + return true; + } + } + if idx.increment().is_err() { + break; + } + } + false + } + + pub fn network(&self) -> Network { + self.network + } +} + +/// ExtendedSpendingKey doesn't implement Zeroize, so zero the secret fields by +/// hand on drop. +impl Drop for SaplingKeyManager { + fn drop(&mut self) { + // SECURITY: zero key material so it can't leak via memory/core dumps or + // swap. Best-effort: write_bytes is non-volatile so the compiler may + // elide it, and copies can still survive in caches/swap/pre-drop dumps. + // Harden with volatile writes + mlock if that matters. + use std::ptr; + + // SAFETY: the pointers come from &mut self, so they are valid, aligned, + // and uniquely owned for the size of each type being overwritten. + unsafe { + let esk_ptr = &mut self.extended_spending_key as *mut ExtendedSpendingKey; + ptr::write_bytes( + esk_ptr as *mut u8, + 0, + std::mem::size_of::(), + ); + + let dfvk_ptr = &mut self.dfvk as *mut DiversifiableFullViewingKey; + ptr::write_bytes( + dfvk_ptr as *mut u8, + 0, + std::mem::size_of::(), + ); + + let div_ptr = &mut self.diversifier_index as *mut DiversifierIndex; + ptr::write_bytes( + div_ptr as *mut u8, + 0, + std::mem::size_of::(), + ); + } + } +} + +pub fn encode_payment_address(hrp: &str, address: &PaymentAddress) -> String { + let bytes = address.to_bytes(); + bech32::encode(hrp, bytes.to_base32(), Variant::Bech32) + .expect("Payment address encoding should not fail") +} + +pub fn decode_payment_address(expected_hrp: &str, encoded: &str) -> SaplingResult { + let (hrp, data, _variant) = + bech32::decode(encoded).map_err(|_| SaplingError::InvalidAddress)?; + + if hrp != expected_hrp { + return Err(SaplingError::InvalidAddress); + } + + let data = Vec::::from_base32(&data).map_err(|_| SaplingError::InvalidAddress)?; + + if data.len() != 43 { + return Err(SaplingError::InvalidAddress); + } + + let bytes: [u8; 43] = data.try_into().map_err(|_| SaplingError::InvalidAddress)?; + + PaymentAddress::from_bytes(&bytes).ok_or(SaplingError::InvalidAddress) +} + +pub fn validate_address(address: &str, network: Network) -> bool { + let expected_hrp = match network { + Network::Mainnet => hrp::PAYMENT_ADDRESS_MAINNET, + Network::Testnet => hrp::PAYMENT_ADDRESS_TESTNET, + }; + + decode_payment_address(expected_hrp, address).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_key_derivation_from_seed() { + let seed = [0u8; 64]; + + let manager = SaplingKeyManager::from_seed(&seed, Network::Mainnet) + .expect("Key derivation should succeed"); + + let address = manager + .default_address() + .expect("Default address should work"); + + let encoded = manager.encode_payment_address(&address); + assert!(encoded.starts_with("ps")); + + let decoded = manager + .decode_payment_address(&encoded) + .expect("Decoding should succeed"); + assert_eq!(address, decoded); + } + + #[test] + fn test_address_derivation() { + let seed = [1u8; 64]; + let mut manager = SaplingKeyManager::from_seed(&seed, Network::Mainnet) + .expect("Key derivation should succeed"); + + let addr1 = manager.next_address().expect("First address"); + let addr2 = manager.next_address().expect("Second address"); + + assert_ne!(addr1, addr2); + } + + #[test] + fn test_viewing_key_encoding() { + let seed = [2u8; 64]; + let manager = SaplingKeyManager::from_seed(&seed, Network::Mainnet) + .expect("Key derivation should succeed"); + + let fvk_encoded = manager.encode_full_viewing_key(); + assert!(fvk_encoded.starts_with("pviews")); + } + + #[test] + fn test_address_validation() { + assert!(!validate_address("invalid", Network::Mainnet)); + + let seed = [3u8; 64]; + let manager = SaplingKeyManager::from_seed(&seed, Network::Mainnet) + .expect("Key derivation should succeed"); + let address = manager.default_address().expect("Default address"); + let encoded = manager.encode_payment_address(&address); + + assert!(validate_address(&encoded, Network::Mainnet)); + assert!(!validate_address(&encoded, Network::Testnet)); + } + + #[test] + fn test_key_manager_drop_zeros_memory() { + // Can't assert the memory is zeroed (reading it post-drop is UB); this + // only exercises that Drop runs without panicking. + let seed = [4u8; 64]; + { + let _manager = SaplingKeyManager::from_seed(&seed, Network::Mainnet) + .expect("Key derivation should succeed"); + } + } +} diff --git a/cw_pivx/rust/src/lib.rs b/cw_pivx/rust/src/lib.rs new file mode 100644 index 0000000000..5ca2626cb3 --- /dev/null +++ b/cw_pivx/rust/src/lib.rs @@ -0,0 +1,122 @@ +//! PIVX Sapling FFI Library for Cake Wallet +//! +//! This library provides C-compatible FFI bindings for PIVX Sapling operations: +//! - Key derivation (ZIP-32) +//! - Note scanning (trial decryption) +//! - Transaction building (Groth16 proofs) +//! +//! # Safety +//! +//! All FFI functions are marked `unsafe` and require: +//! - Valid non-null pointers where specified +//! - Proper memory management (caller must free returned strings/buffers) +//! - Thread-safe usage patterns + +pub mod error; +pub mod ffi; +pub mod keys; +pub mod notes; +pub mod prover; +pub mod sync; +pub mod transaction; +pub mod types; +pub mod utils; + +use std::ffi::{c_char, c_uchar, CStr, CString}; +use std::ptr; + +pub use error::*; +pub use keys::{ + decode_payment_address, encode_payment_address, hrp, validate_address, SaplingKeyManager, +}; +pub use notes::{select_notes_for_amount, CompactNote, SpendableNote}; +pub use sync::{SyncProgress, SyncState, SAPLING_TREE_DEPTH}; +pub use transaction::{ + BuiltTransaction, PivxMainnet, PivxTestnet, TransactionBuilder, TransactionOptions, + TransactionOutput, PIVX_SAPLING_ACTIVATION, +}; +pub use types::Network; + +pub use ffi::*; + +/// Caller must free the returned string with `pivx_free_string`. +#[no_mangle] +pub extern "C" fn pivx_sapling_version() -> *mut c_char { + let version = env!("CARGO_PKG_VERSION"); + match CString::new(version) { + Ok(s) => s.into_raw(), + Err(_) => ptr::null_mut(), + } +} + +/// Free a string allocated by this library. +/// +/// # Safety +/// The pointer must have been allocated by this library and not already freed. +#[no_mangle] +pub unsafe extern "C" fn pivx_free_string(s: *mut c_char) { + if !s.is_null() { + let len = CStr::from_ptr(s).to_bytes_with_nul().len(); + crate::ffi::zero_ffi_allocation(s.cast::(), len); + drop(CString::from_raw(s)); + } +} + +/// Free a byte buffer allocated by this library. +/// +/// # Safety +/// The pointer must have been allocated by this library and not already freed. +#[no_mangle] +pub unsafe extern "C" fn pivx_free_buffer(ptr: *mut c_uchar, len: usize) { + if !ptr.is_null() && len > 0 { + crate::ffi::zero_ffi_allocation(ptr.cast::(), len); + drop(Vec::from_raw_parts(ptr, len, len)); + } +} + +thread_local! { + static LAST_ERROR: std::cell::RefCell> = std::cell::RefCell::new(None); +} + +/// Get the last error message. +/// Returns null if no error occurred. +/// Caller must free the returned string with `pivx_free_string`. +#[no_mangle] +pub extern "C" fn pivx_get_last_error() -> *mut c_char { + LAST_ERROR.with(|e| match e.borrow().as_ref() { + Some(msg) => CString::new(msg.as_str()) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()), + None => ptr::null_mut(), + }) +} + +/// Clear the last error. +#[no_mangle] +pub extern "C" fn pivx_clear_last_error() { + LAST_ERROR.with(|e| { + *e.borrow_mut() = None; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CStr; + + #[test] + fn test_init() { + assert_eq!(pivx_sapling_init(), 0); + } + + #[test] + fn test_version() { + let version = pivx_sapling_version(); + assert!(!version.is_null()); + unsafe { + let s = CStr::from_ptr(version).to_str().unwrap(); + assert!(!s.is_empty()); + pivx_free_string(version); + } + } +} diff --git a/cw_pivx/rust/src/notes.rs b/cw_pivx/rust/src/notes.rs new file mode 100644 index 0000000000..103a4fb681 --- /dev/null +++ b/cw_pivx/rust/src/notes.rs @@ -0,0 +1,410 @@ +//! PIVX Sapling note management. +//! +//! Handles Sapling notes (the fundamental unit of shielded value) +//! including decryption, nullifier computation, and spending. + +use sapling::{Note, Nullifier, PaymentAddress}; + +use crate::error::SaplingError; + +pub type SaplingResult = Result; + +/// A decrypted Sapling note that can be spent. +#[derive(Clone, Debug)] +pub struct SpendableNote { + pub note: Note, + pub address: PaymentAddress, + pub position: u64, + pub nullifier: Nullifier, + pub height: u32, + pub tx_index: u32, + pub output_index: u32, + pub is_spent: bool, + /// Decrypted memo for received notes; None when empty or on the spend path. + pub memo: Option, +} + +impl SpendableNote { + /// Value in zatoshis. + pub fn value(&self) -> u64 { + self.note.value().inner() + } + + pub fn mark_spent(&mut self) { + self.is_spent = true; + } + + pub fn new( + note: Note, + address: PaymentAddress, + position: u64, + nullifier: Nullifier, + height: u32, + tx_index: u32, + output_index: u32, + ) -> Self { + Self { + note, + address, + position, + nullifier, + height, + tx_index, + output_index, + is_spent: false, + memo: None, + } + } +} + +/// A compact note for sync (subset of full note data). +#[derive(Clone, Debug)] +pub struct CompactNote { + pub cmu: [u8; 32], + pub epk: [u8; 32], + /// First 52 bytes of the encrypted ciphertext. + pub enc_ciphertext: [u8; 52], +} + +/// Greedily select unspent notes covering target_amount + fee. +pub fn select_notes_for_amount( + notes: &[SpendableNote], + target_amount: u64, + fee: u64, +) -> SaplingResult> { + let total_needed = target_amount + fee; + + let mut available: Vec<_> = notes.iter().filter(|n| !n.is_spent).cloned().collect(); + available.sort_by(|a, b| b.value().cmp(&a.value())); + + let mut selected = Vec::new(); + let mut selected_total = 0u64; + + for note in available { + if selected_total >= total_needed { + break; + } + selected_total += note.value(); + selected.push(note); + } + + if selected_total < total_needed { + return Err(SaplingError::InsufficientFunds); + } + + Ok(selected) +} + +/// Parse a merkle path from hex-encoded witness data. +/// +/// The ElectrumX witness is 32 sibling hashes, 32 bytes each (1024 bytes). +pub fn parse_merkle_path(witness_hex: &str, position: u64) -> SaplingResult { + use incrementalmerkletree::Position; + use sapling::Node; + + const SAPLING_TREE_DEPTH: usize = 32; + const NODE_SIZE: usize = 32; + + let witness_bytes = hex::decode(witness_hex).map_err(|_| SaplingError::InvalidWitness)?; + + if witness_bytes.len() != SAPLING_TREE_DEPTH * NODE_SIZE { + return Err(SaplingError::InvalidWitness); + } + + let mut path_elems = Vec::with_capacity(SAPLING_TREE_DEPTH); + for i in 0..SAPLING_TREE_DEPTH { + let start = i * NODE_SIZE; + let end = start + NODE_SIZE; + let node_bytes: [u8; 32] = witness_bytes[start..end] + .try_into() + .map_err(|_| SaplingError::InvalidWitness)?; + + let node_opt = Node::from_bytes(node_bytes); + if bool::from(node_opt.is_none()) { + return Err(SaplingError::InvalidWitness); + } + path_elems.push(node_opt.unwrap()); // Safe: just checked is_none() + } + + let pos = Position::from(position); + sapling::MerklePath::from_parts(path_elems, pos).map_err(|_| SaplingError::InvalidWitness) +} + +/// Recompute the Sapling Merkle root from a witness and compare it to an +/// expected anchor. +/// +/// The witness is parsed with [`parse_merkle_path`], the same routine used to +/// build spend `MerklePath`s, so verification covers exactly the bytes that +/// would enter proof construction. Non-canonical cmu or anchor bytes are +/// errors, not mismatches. +/// +/// Returns `Ok(true)` when the recomputed root equals `expected_anchor`, +/// `Ok(false)` on a clean mismatch. +pub fn verify_witness_root( + witness_hex: &str, + position: u64, + cmu: [u8; 32], + expected_anchor: [u8; 32], +) -> SaplingResult { + use sapling::note::ExtractedNoteCommitment; + use sapling::{Anchor, Node}; + + let path = parse_merkle_path(witness_hex, position)?; + + let cmu = match ExtractedNoteCommitment::from_bytes(&cmu).into_option() { + Some(cmu) => cmu, + None => { + return Err(SaplingError::InvalidInput( + "Non-canonical note commitment bytes".into(), + )) + } + }; + let expected = match Anchor::from_bytes(expected_anchor).into_option() { + Some(anchor) => anchor, + None => return Err(SaplingError::InvalidAnchor), + }; + + let root = Anchor::from(path.root(Node::from_cmu(&cmu))); + Ok(root.to_bytes() == expected.to_bytes()) +} + +/// Reconstruct a Note from serialized data. +/// +/// This is used when loading saved notes from storage for spending. +pub fn note_from_parts( + diversifier_hex: &str, + pk_d_hex: &str, + value: u64, + rseed_hex: &str, +) -> SaplingResult<(Note, PaymentAddress)> { + use sapling::{value::NoteValue, Rseed}; + + let diversifier_bytes: [u8; 11] = hex::decode(diversifier_hex) + .map_err(|_| SaplingError::InvalidInput("invalid diversifier hex".into()))? + .try_into() + .map_err(|_| SaplingError::InvalidInput("diversifier must be 11 bytes".into()))?; + + let pk_d_bytes: [u8; 32] = hex::decode(pk_d_hex) + .map_err(|_| SaplingError::InvalidInput("invalid pk_d hex".into()))? + .try_into() + .map_err(|_| SaplingError::InvalidInput("pk_d must be 32 bytes".into()))?; + + // Construct the 43-byte payment address: diversifier (11) + pk_d (32) + let mut addr_bytes = [0u8; 43]; + addr_bytes[..11].copy_from_slice(&diversifier_bytes); + addr_bytes[11..].copy_from_slice(&pk_d_bytes); + + let address = PaymentAddress::from_bytes(&addr_bytes).ok_or(SaplingError::InvalidAddress)?; + + // Parse rseed (32 bytes); for pre-ZIP-212 this is rcm + let rseed_bytes: [u8; 32] = hex::decode(rseed_hex) + .map_err(|_| SaplingError::InvalidInput("invalid rseed hex".into()))? + .try_into() + .map_err(|_| SaplingError::InvalidInput("rseed must be 32 bytes".into()))?; + + // For PIVX (pre-ZIP-212), rseed is the commitment randomness directly + let fr_opt = jubjub::Fr::from_bytes(&rseed_bytes); + if bool::from(fr_opt.is_none()) { + return Err(SaplingError::InvalidInput("invalid rseed scalar".into())); + } + let rseed = Rseed::BeforeZip212(fr_opt.unwrap()); // Safe: just checked is_none() + + let note = Note::from_parts(address.clone(), NoteValue::from_raw(value), rseed); + + Ok((note, address)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_note_selection_insufficient() { + let notes: Vec = vec![]; + let result = select_notes_for_amount(¬es, 1000, 100); + assert!(result.is_err()); + } + + /// 32 canonical sibling nodes (value 1, little-endian) as witness hex. + pub(crate) fn test_witness_hex() -> String { + let mut node = [0u8; 32]; + node[0] = 1; + hex::encode(node).repeat(32) + } + + /// Canonical cmu bytes (value 2, little-endian). + pub(crate) fn test_cmu_bytes() -> [u8; 32] { + let mut cmu = [0u8; 32]; + cmu[0] = 2; + cmu + } + + /// The anchor that test_witness_hex + test_cmu_bytes recompute to. + pub(crate) fn test_expected_anchor() -> [u8; 32] { + use sapling::note::ExtractedNoteCommitment; + use sapling::{Anchor, Node}; + + let path = parse_merkle_path(&test_witness_hex(), 0).unwrap(); + let cmu = ExtractedNoteCommitment::from_bytes(&test_cmu_bytes()) + .into_option() + .unwrap(); + Anchor::from(path.root(Node::from_cmu(&cmu))).to_bytes() + } + + #[test] + fn test_verify_witness_root_accepts_matching_anchor() { + let result = verify_witness_root( + &test_witness_hex(), + 0, + test_cmu_bytes(), + test_expected_anchor(), + ); + assert!(matches!(result, Ok(true))); + } + + #[test] + fn test_verify_witness_root_rejects_tampered_sibling() { + // Flip one sibling from value 1 to value 3 (still canonical) so the + // failure is a clean root mismatch, not a parse error. + let mut witness = test_witness_hex(); + witness.replace_range(0..2, "03"); + + let result = + verify_witness_root(&witness, 0, test_cmu_bytes(), test_expected_anchor()); + assert!(matches!(result, Ok(false))); + } + + #[test] + fn test_verify_witness_root_position_changes_root() { + // Same siblings and leaf at a different position must not verify. + let result = verify_witness_root( + &test_witness_hex(), + 1, + test_cmu_bytes(), + test_expected_anchor(), + ); + assert!(matches!(result, Ok(false))); + } + + #[test] + fn test_verify_witness_root_rejects_non_canonical_bytes() { + // Non-canonical cmu is an error, not a mismatch. + assert!(verify_witness_root( + &test_witness_hex(), + 0, + [0xff; 32], + test_expected_anchor() + ) + .is_err()); + + // Non-canonical anchor is an error, not a mismatch. + assert!( + verify_witness_root(&test_witness_hex(), 0, test_cmu_bytes(), [0xff; 32]) + .is_err() + ); + + // Non-canonical sibling node in the witness is an error. + let mut witness = test_witness_hex(); + witness.replace_range(0..64, &"ff".repeat(32)); + assert!(verify_witness_root( + &witness, + 0, + test_cmu_bytes(), + test_expected_anchor() + ) + .is_err()); + + // Truncated witness is an error. + assert!(verify_witness_root( + &test_witness_hex()[..64 * 31], + 0, + test_cmu_bytes(), + test_expected_anchor() + ) + .is_err()); + } + + // Real spendable witness captured from electrum02.chainster.org + // (pivx.sapling.electrumx.v1, hex_byte_order=display), global_position 0. + // Determines empirically which byte order the prover needs. + fn chainster_fixture() -> (String, [u8; 32], [u8; 32]) { + const CMU_DISPLAY: &str = + "219abc22220f9e133c4414d9462b9d86e3c8fb1b6ccda36ff0d919c5f6588a95"; + const ANCHOR_DISPLAY: &str = + "23ad2c39c720e69af6cf5c7cca8aa501d7a36964ba7d9755659b242fb6dd06db"; + const PATH: [&str; 32] = [ + "7352fa42ff23e572387ba965db04bdc6fd6cab74b97338c4c79948c6dc4bc33c", + "ce75b04ebdcf92ea0cab93bf5fc2cd675fc867accacb42550f357950b8fc3a14", + "6875488967e1008d7fec44841dab10a7c244266bdb936a9fad10e798da1a5b39", + "76fe6c77f4f4603669b1159e519329f97744e69dcffef6b6266cf5c3c916eb31", + "61022337bf970d2de80803684e0fe6248c3c6a7ad581433ffda690cdc8ec0a42", + "938988a2c5c64733c988336bff7b5d8416277036363aeaad0968afffe665de1b", + "30d3896b4ead5b4c9db948361c6466acc6bc0a6d44af52b5ce75a107ff186b51", + "ac787541cd73929dca61aff447c2995ac74ec0c59f3a769ce02553162ea9162c", + "3ec002c09ed73b1133790de0cf66a847ba5495e2568e0c05d4a07ce691b14d0a", + "273e391d61d8df4c83d402ed2e46702c81841092e3a9499bc72082d0c5fc241c", + "e401f0174fefa0bd37301482536d9541ef16b48d2a5f75077bc9c55eaf35ac4e", + "53925b451d437417eb98769352a43b8456f444c7e6374a25d6872be946090134", + "b9e09e33386178a9254c48f516a17321a282fba02d4b77bce690be8563ee3122", + "10c0eec61907cef40126df0126ff8d0605643116f62aaa6b8cc0b2839ed4af1e", + "49453ebd0c7871ff489ffc45714ef15cdd027053bcf94c4a64a220d473b7a10a", + "af1e4b9097509e5be5765725c27ae59e0819e64649aee556c72d773b08ea500a", + "1ea6675f9551eeb9dfaaa9247bc9858270d3d3a4c5afa7177a984d5ed1be2451", + "6edb16d01907b759977d7650dad7e3ec049af1a3d875380b697c862c9ec5d51c", + "cd1c8dbf6e3acc7a80439bc4962cf25b9dce7c896f3a5bd70803fc5a0e33cf00", + "6aca8448d8263e547d5ff2950e2ed3839e998d31cbc6ac9fd57bc6002b159216", + "8d5fa43e5a10d11605ac7430ba1f5d81fb1b68d29a640405767749e841527673", + "08eeab0c13abd6069e6310197bf80f9c1ea6de78fd19cbae24d4a520e6cf3023", + "0769557bc682b1bf308646fd0b22e648e8b9e98f57e29f5af40f6edb833e2c49", + "4c6937d78f42685f84b43ad3b7b00f81285662f85c6a68ef11d62ad1a3ee0850", + "fee0e52802cb0c46b1eb4d376c62697f4759f6c8917fa352571202fd778fd712", + "16d6252968971a83da8521d65382e61f0176646d771c91528e3276ee45383e4a", + "d2e1642c9a462229289e5b0e3b7f9008e0301cbb93385ee0e21da2545073cb58", + "a5122c08ff9c161d9ca6fc462073396c7d7d38e8ee48cdb3bea7e2230134ed6a", + "28e7b841dcbc47cceb69d7cb8d94245fb7cb2ba3a7a6bc18f13f945f7dbd6e2a", + "e1f34b034d4a3cd28557e2907ebf990c918f64ecb50a94f01d6fda5ca5c7ef72", + "12935f14b676509b81eb49ef25f39269ed72309238b4c145803544b646dca62d", + "b2eed031d4d6a4f02a097f80b54cc1541d4163c6b6f5971f88b6e41d35c53814", + ]; + let to32 = |s: &str| -> [u8; 32] { + hex::decode(s).unwrap().try_into().unwrap() + }; + (PATH.join(""), to32(CMU_DISPLAY), to32(ANCHOR_DISPLAY)) + } + + fn rev(mut b: [u8; 32]) -> [u8; 32] { + b.reverse(); + b + } + + #[test] + fn chainster_v1_witness_needs_display_to_serialization_reversal() { + let (path, cmu_disp, anchor_disp) = chainster_fixture(); + // Try every cmu/anchor byte-order combination against the raw path. + let combos: [(&str, [u8; 32], [u8; 32]); 4] = [ + ("display/display", cmu_disp, anchor_disp), + ("reversed/reversed", rev(cmu_disp), rev(anchor_disp)), + ("reversed/display", rev(cmu_disp), anchor_disp), + ("display/reversed", cmu_disp, rev(anchor_disp)), + ]; + let mut winner = None; + for (label, cmu, anchor) in combos { + let r = verify_witness_root(&path, 0, cmu, anchor); + if matches!(r, Ok(true)) { + winner = Some(label); + } + } + // The wallet currently passes display order as-is; prove that fails and + // that reversing cmu+anchor (display -> serialization) is what works. + assert!( + matches!(verify_witness_root(&path, 0, cmu_disp, anchor_disp), Ok(false)) + || verify_witness_root(&path, 0, cmu_disp, anchor_disp).is_err(), + "display-as-is must NOT verify (that is the send regression)" + ); + assert_eq!( + winner, + Some("reversed/reversed"), + "reversing cmu+anchor from display to serialization order must verify" + ); + } +} diff --git a/cw_pivx/rust/src/prover.rs b/cw_pivx/rust/src/prover.rs new file mode 100644 index 0000000000..bbcfae05dd --- /dev/null +++ b/cw_pivx/rust/src/prover.rs @@ -0,0 +1,144 @@ +//! Sapling prover using Groth16 proofs. +//! +//! This module handles loading proving parameters and generating +//! zero-knowledge proofs for Sapling transactions. + +use std::fs; +use std::path::Path; +use std::sync::Mutex; + +use lazy_static::lazy_static; +use sha2::{Digest, Sha256}; +use zcash_proofs::prover::LocalTxProver; + +use crate::error::SaplingError; + +lazy_static! { + /// Global prover instance (expensive to create, reuse across transactions). + static ref PROVER: Mutex> = Mutex::new(None); +} + +/// Expected SHA256 hash of sapling-spend.params (Zcash Sapling parameters). +/// These parameters are the same for PIVX as they are based on the Zcash Sapling protocol. +/// Current wallet download source: https://duddino.com/sapling-spend.params +pub const EXPECTED_SPEND_HASH: &str = + "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"; + +/// Expected SHA256 hash of sapling-output.params. +/// Current wallet download source: https://duddino.com/sapling-output.params +pub const EXPECTED_OUTPUT_HASH: &str = + "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"; + +fn verify_param_hash(path: &str, expected: &str) -> Result<(), SaplingError> { + let data = fs::read(path).map_err(|e| { + SaplingError::InvalidInput(format!("Failed to read parameter file {}: {}", path, e)) + })?; + + let hash = Sha256::digest(&data); + let hash_hex = hex::encode(hash); + + if hash_hex != expected { + return Err(SaplingError::InvalidInput(format!( + "Parameter file {} hash mismatch.\nExpected: {}\nGot: {}\n\ + This could indicate a corrupted or malicious parameter file. \ + Please re-download the proving parameters.", + path, expected, hash_hex + ))); + } + + Ok(()) +} + +pub fn has_proving_params(params_dir: &str) -> bool { + let spend_path = format!("{}/sapling-spend.params", params_dir); + let output_path = format!("{}/sapling-output.params", params_dir); + + Path::new(&spend_path).exists() + && Path::new(&output_path).exists() + && verify_param_hash(&spend_path, EXPECTED_SPEND_HASH).is_ok() + && verify_param_hash(&output_path, EXPECTED_OUTPUT_HASH).is_ok() +} + +/// Initialize the prover with parameters from a directory. +/// +/// The directory should contain: +/// - sapling-spend.params (47 MB, verified by SHA256) +/// - sapling-output.params (3.6 MB, verified by SHA256) +/// +/// Hash verification prevents the use of corrupted or backdoored parameters. +pub fn init_prover(params_dir: &str) -> Result<(), SaplingError> { + let spend_path = format!("{}/sapling-spend.params", params_dir); + let output_path = format!("{}/sapling-output.params", params_dir); + + if !Path::new(&spend_path).exists() { + return Err(SaplingError::InvalidInput(format!( + "Spend params not found: {}", + spend_path + ))); + } + if !Path::new(&output_path).exists() { + return Err(SaplingError::InvalidInput(format!( + "Output params not found: {}", + output_path + ))); + } + + // Verify parameter file hashes (CRITICAL SECURITY CHECK) + verify_param_hash(&spend_path, EXPECTED_SPEND_HASH)?; + verify_param_hash(&output_path, EXPECTED_OUTPUT_HASH)?; + + let prover = LocalTxProver::new(Path::new(&spend_path), Path::new(&output_path)); + + let mut global = PROVER + .lock() + .map_err(|_| SaplingError::InvalidInput("Failed to lock prover mutex".into()))?; + *global = Some(prover); + + Ok(()) +} + +pub fn is_prover_initialized() -> bool { + PROVER.lock().map(|p| p.is_some()).unwrap_or(false) +} + +pub fn get_prover() -> Result>, SaplingError> { + let guard = PROVER + .lock() + .map_err(|_| SaplingError::InvalidInput("Failed to lock prover mutex".into()))?; + + if guard.is_none() { + return Err(SaplingError::ProverNotInitialized); + } + + Ok(guard) +} + +pub fn dispose_prover() { + if let Ok(mut guard) = PROVER.lock() { + *guard = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expected_param_hashes_are_canonical_sha256() { + assert_eq!(EXPECTED_SPEND_HASH.len(), 64); + assert_eq!(EXPECTED_OUTPUT_HASH.len(), 64); + assert_eq!( + EXPECTED_SPEND_HASH, + "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" + ); + assert_eq!( + EXPECTED_OUTPUT_HASH, + "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" + ); + } + + #[test] + fn test_prover_not_initialized() { + assert!(!is_prover_initialized()); + } +} diff --git a/cw_pivx/rust/src/sync.rs b/cw_pivx/rust/src/sync.rs new file mode 100644 index 0000000000..857cbc8a1f --- /dev/null +++ b/cw_pivx/rust/src/sync.rs @@ -0,0 +1,139 @@ +//! PIVX Sapling blockchain synchronization. +//! +//! Manages sync state including the commitment tree and note witnesses. + +use sapling::Nullifier; + +use crate::error::SaplingError; +use crate::notes::SpendableNote; + +pub type SaplingResult = Result; + +pub const SAPLING_TREE_DEPTH: u8 = 32; + +pub struct SyncState { + sync_height: u32, + nullifier_set: Vec, + notes: Vec, + commitment_count: u64, +} + +impl SyncState { + pub fn new() -> Self { + Self { + sync_height: 0, + nullifier_set: Vec::new(), + notes: Vec::new(), + commitment_count: 0, + } + } + + pub fn from_height(height: u32) -> Self { + let mut state = Self::new(); + state.sync_height = height; + state + } + + pub fn sync_height(&self) -> u32 { + self.sync_height + } + + pub fn tree_position(&self) -> u64 { + self.commitment_count + } + + pub fn increment_commitment_count(&mut self) { + self.commitment_count += 1; + } + + pub fn add_note(&mut self, note: SpendableNote) -> SaplingResult<()> { + // dedupe by tree position (globally unique per output). a resume can + // restore a note then re-scan its block and decrypt it again; without + // this the native list gets duplicate rows and a multi-input send can + // pick the same nullifier twice. + if self.notes.iter().any(|n| n.position == note.position) { + return Ok(()); + } + self.notes.push(note); + Ok(()) + } + + pub fn is_nullifier_spent(&self, nullifier: &Nullifier) -> bool { + self.nullifier_set.iter().any(|n| n == nullifier) + } + + pub fn add_spent_nullifier(&mut self, nullifier: Nullifier) { + if !self.is_nullifier_spent(&nullifier) { + self.nullifier_set.push(nullifier); + + for note in &mut self.notes { + if note.nullifier == nullifier { + note.is_spent = true; + } + } + } + } + + pub fn unspent_notes(&self) -> Vec<&SpendableNote> { + self.notes.iter().filter(|n| !n.is_spent).collect() + } + + pub fn shielded_balance(&self) -> u64 { + self.notes + .iter() + .filter(|n| !n.is_spent) + .map(|n| n.value()) + .sum() + } + + pub fn set_sync_height(&mut self, height: u32) { + self.sync_height = height; + } +} + +impl Default for SyncState { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Debug)] +pub struct SyncProgress { + pub current_block: u32, + pub target_block: u32, + pub notes_found: usize, + pub eta_seconds: Option, +} + +impl SyncProgress { + pub fn progress_percent(&self) -> f64 { + if self.target_block == 0 { + return 100.0; + } + (self.current_block as f64 / self.target_block as f64) * 100.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sync_state_new() { + let state = SyncState::new(); + assert_eq!(state.sync_height(), 0); + assert_eq!(state.shielded_balance(), 0); + } + + #[test] + fn test_sync_progress() { + let progress = SyncProgress { + current_block: 50, + target_block: 100, + notes_found: 5, + eta_seconds: Some(60), + }; + + assert!((progress.progress_percent() - 50.0).abs() < 0.01); + } +} diff --git a/cw_pivx/rust/src/transaction.rs b/cw_pivx/rust/src/transaction.rs new file mode 100644 index 0000000000..f1e3f3ea88 --- /dev/null +++ b/cw_pivx/rust/src/transaction.rs @@ -0,0 +1,1721 @@ +//! PIVX Sapling transaction building. +//! +//! Builds shielded transactions using Groth16 proofs. + +use rand::rngs::OsRng; +use sapling::{ + builder::{Builder as SaplingBuilder, BundleType}, + note_encryption::Zip212Enforcement, + value::NoteValue, + zip32::{DiversifiableFullViewingKey, ExtendedSpendingKey}, + Anchor, MerklePath, Node, PaymentAddress, SaplingVerificationContext, +}; +use zcash_primitives::zip32::Scope; +use zcash_protocol::consensus::{BlockHeight, NetworkType, NetworkUpgrade, Parameters}; + +use crate::error::SaplingError; +use crate::notes::SpendableNote; +use crate::prover; + +pub type SaplingResult = Result; + +/// PIVX max supply: 21,000,000 coins = 21,000,000,000,000 zatoshis (21 trillion zatoshis). +pub const PIVX_MAX_SUPPLY: u64 = 21_000_000_000_000u64; + +/// Shielded dust threshold derived from PIVX Core v5.6.1: +/// 100 * dustRelayFee.GetFee(384-byte spend + 34-byte txout + 64-byte binding sig). +pub const SHIELDED_DUST_THRESHOLD: u64 = 1_446_000u64; + +/// Transparent dust threshold derived from PIVX Core v5.6.1: +/// dustRelayFee.GetFee(182) with dust relay fee 30,000 zatoshis/kB. +pub const TRANSPARENT_DUST_THRESHOLD: u64 = 5_460u64; + +/// PIVX base58check address prefixes (PIVX Core src/chainparams.cpp). +const PIVX_MAINNET_PUBKEY_PREFIX: u8 = 30; // 'D...' +const PIVX_MAINNET_SCRIPT_PREFIX: u8 = 13; // '6...' +const PIVX_TESTNET_PUBKEY_PREFIX: u8 = 139; // 'x.../y...' +const PIVX_TESTNET_SCRIPT_PREFIX: u8 = 19; // '8.../9...' + +/// A transparent output for shielded-to-transparent (deshield) transactions. +#[derive(Debug, Clone)] +pub struct TransparentOutput { + /// Value in zatoshis. + pub value: u64, + /// Raw scriptPubKey bytes. + pub script_pubkey: Vec, +} + +impl TransparentOutput { + /// Build a transparent output paying a PIVX base58check address. + pub fn to_address(address: &str, value: u64, testnet: bool) -> SaplingResult { + let script_pubkey = script_pubkey_for_transparent_address(address, testnet)?; + Ok(TransparentOutput { + value, + script_pubkey, + }) + } + + fn serialize_into(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.value.to_le_bytes()); + write_compact_size(buf, self.script_pubkey.len() as u64); + buf.extend_from_slice(&self.script_pubkey); + } +} + +fn write_compact_size(buf: &mut Vec, n: u64) { + if n < 0xfd { + buf.push(n as u8); + } else if n <= 0xffff { + buf.push(0xfd); + buf.extend_from_slice(&(n as u16).to_le_bytes()); + } else if n <= 0xffffffff { + buf.push(0xfe); + buf.extend_from_slice(&(n as u32).to_le_bytes()); + } else { + buf.push(0xff); + buf.extend_from_slice(&n.to_le_bytes()); + } +} + +/// Serialize transparent outputs exactly as PIVX Core serializes `vout` +/// (compact size count, then per output: value LE64 + scriptPubKey). +pub fn serialize_transparent_outputs(buf: &mut Vec, outputs: &[TransparentOutput]) { + write_compact_size(buf, outputs.len() as u64); + for output in outputs { + output.serialize_into(buf); + } +} + +/// Decode a PIVX base58check transparent address into its scriptPubKey. +/// +/// Supports P2PKH and P2SH for the requested network and rejects +/// wrong-network or malformed addresses. +pub fn script_pubkey_for_transparent_address( + address: &str, + testnet: bool, +) -> SaplingResult> { + let payload = base58check_decode(address)?; + if payload.len() != 21 { + return Err(SaplingError::InvalidInput( + "Transparent address payload must be 21 bytes".into(), + )); + } + let (pubkey_prefix, script_prefix) = if testnet { + (PIVX_TESTNET_PUBKEY_PREFIX, PIVX_TESTNET_SCRIPT_PREFIX) + } else { + (PIVX_MAINNET_PUBKEY_PREFIX, PIVX_MAINNET_SCRIPT_PREFIX) + }; + let version = payload[0]; + let hash = &payload[1..21]; + if version == pubkey_prefix { + // OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG + let mut script = Vec::with_capacity(25); + script.push(0x76); + script.push(0xa9); + script.push(0x14); + script.extend_from_slice(hash); + script.push(0x88); + script.push(0xac); + Ok(script) + } else if version == script_prefix { + // OP_HASH160 <20 bytes> OP_EQUAL + let mut script = Vec::with_capacity(23); + script.push(0xa9); + script.push(0x14); + script.extend_from_slice(hash); + script.push(0x87); + Ok(script) + } else { + Err(SaplingError::InvalidInput( + "Transparent address version does not match the selected PIVX network".into(), + )) + } +} + +const BASE58_ALPHABET: &[u8; 58] = + b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +fn base58check_decode(input: &str) -> SaplingResult> { + if input.is_empty() || input.len() > 100 { + return Err(SaplingError::InvalidInput( + "Invalid base58 address length".into(), + )); + } + + let mut bytes: Vec = Vec::with_capacity(25); + for ch in input.bytes() { + let digit = BASE58_ALPHABET + .iter() + .position(|&c| c == ch) + .ok_or_else(|| { + SaplingError::InvalidInput("Invalid base58 character in address".into()) + })? as u32; + let mut carry = digit; + for byte in bytes.iter_mut() { + carry += (*byte as u32) * 58; + *byte = (carry & 0xff) as u8; + carry >>= 8; + } + while carry > 0 { + bytes.push((carry & 0xff) as u8); + carry >>= 8; + } + } + // Leading '1' characters encode leading zero bytes. + for ch in input.bytes() { + if ch == b'1' { + bytes.push(0); + } else { + break; + } + } + bytes.reverse(); + + if bytes.len() < 5 { + return Err(SaplingError::InvalidInput( + "Base58 address payload too short".into(), + )); + } + let (payload, checksum) = bytes.split_at(bytes.len() - 4); + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(Sha256::digest(payload)); + if digest[..4] != checksum[..] { + return Err(SaplingError::InvalidInput( + "Base58 address checksum mismatch".into(), + )); + } + Ok(payload.to_vec()) +} + +/// A transparent UTXO being spent in a transparent-to-shielded (shield) +/// transaction, with its signing key. +pub struct TransparentInput { + /// Previous output txid in internal byte order (reversed display hex). + pub prevout_txid: [u8; 32], + /// Previous output index. + pub prevout_index: u32, + /// UTXO value in zatoshis. + pub value: u64, + /// The UTXO's scriptPubKey (must be P2PKH). + pub script_pubkey: Vec, + secret_key: secp256k1::SecretKey, +} + +impl TransparentInput { + /// Default Bitcoin/PIVX sequence number. + pub const SEQUENCE_FINAL: u32 = 0xffff_ffff; + + /// Parse and validate a UTXO + key pair. + /// + /// The private key must be 32-byte hex, and the compressed public key it + /// derives must hash to the P2PKH hash in `script_pubkey` so a wrong key + /// fails closed before signing. + pub fn from_parts( + txid_hex: &str, + vout: u32, + value: u64, + script_pubkey_hex: &str, + private_key_hex: &str, + ) -> SaplingResult { + let txid_display = hex::decode(txid_hex) + .map_err(|_| SaplingError::InvalidInput("Invalid UTXO txid hex".into()))?; + if txid_display.len() != 32 { + return Err(SaplingError::InvalidInput( + "UTXO txid must be 32 bytes".into(), + )); + } + let mut prevout_txid = [0u8; 32]; + prevout_txid.copy_from_slice(&txid_display); + prevout_txid.reverse(); + + let script_pubkey = hex::decode(script_pubkey_hex) + .map_err(|_| SaplingError::InvalidInput("Invalid UTXO script hex".into()))?; + if script_pubkey.len() != 25 + || script_pubkey[0] != 0x76 + || script_pubkey[1] != 0xa9 + || script_pubkey[2] != 0x14 + || script_pubkey[23] != 0x88 + || script_pubkey[24] != 0xac + { + return Err(SaplingError::InvalidInput( + "Shield inputs must be P2PKH UTXOs".into(), + )); + } + + let key_bytes = hex::decode(private_key_hex).map_err(|_| { + SaplingError::InvalidInput("UTXO private key must be 32-byte hex".into()) + })?; + if key_bytes.len() != 32 { + return Err(SaplingError::InvalidInput( + "UTXO private key must be 32 bytes".into(), + )); + } + let secret_key = secp256k1::SecretKey::from_slice(&key_bytes) + .map_err(|_| SaplingError::InvalidInput("Invalid UTXO private key".into()))?; + + let secp = secp256k1::Secp256k1::signing_only(); + let pubkey = secret_key.public_key(&secp).serialize(); + use ripemd::Ripemd160; + use sha2::{Digest, Sha256}; + let pubkey_hash = Ripemd160::digest(Sha256::digest(pubkey)); + if pubkey_hash[..] != script_pubkey[3..23] { + return Err(SaplingError::InvalidInput( + "UTXO private key does not match the script public key hash".into(), + )); + } + + Ok(TransparentInput { + prevout_txid, + prevout_index: vout, + value, + script_pubkey, + secret_key, + }) + } + + fn serialize_prevout(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.prevout_txid); + buf.extend_from_slice(&self.prevout_index.to_le_bytes()); + } + + /// Sign the per-input sighash and assemble the P2PKH scriptSig + /// (push(DER signature + SIGHASH_ALL) push(compressed pubkey)). + fn script_sig(&self, sighash: [u8; 32]) -> Vec { + let secp = secp256k1::Secp256k1::signing_only(); + let message = secp256k1::Message::from_digest(sighash); + let signature = secp.sign_ecdsa(&message, &self.secret_key); + let mut der = signature.serialize_der().to_vec(); + der.push(0x01); // SIGHASH_ALL + let pubkey = self.secret_key.public_key(&secp).serialize(); + + let mut script_sig = Vec::with_capacity(2 + der.len() + pubkey.len()); + script_sig.push(der.len() as u8); + script_sig.extend_from_slice(&der); + script_sig.push(pubkey.len() as u8); + script_sig.extend_from_slice(&pubkey); + script_sig + } +} + +fn pivx_sighash_personalization() -> [u8; 16] { + let mut personalization = [0u8; 16]; + let prefix = b"PIVXSigHash"; + personalization[..prefix.len()].copy_from_slice(prefix); + // PIVX uses branch ID 0 (not Zcash's 0x03C48270) + personalization[12..16].copy_from_slice(&0u32.to_le_bytes()); + personalization +} + +/// Validate transaction amounts to prevent overflow and invalid sums. +/// +/// Checks: +/// - Integer overflow protection +/// - Max supply limits +/// - Dust threshold +/// - Inputs cover outputs plus fee; change is added later by the builder +pub fn validate_transaction_amounts( + input_amounts: &[u64], + output_amounts: &[u64], + fee: u64, +) -> Result<(), SaplingError> { + // No absolute fee cap: PIVX Core enforces no fixed maximum (only a relative + // GetShieldedTxMinFee*100 guard under fRejectAbsurdFee). The planner sets the + // fee to the exact required minimum, and for a deshield this value carries + // the transparent output total on top, so a fixed cap here wrongly rejects + // any deshield above the cap. The min-fee floor is enforced by the network. + for (i, &amount) in output_amounts.iter().enumerate() { + if amount < SHIELDED_DUST_THRESHOLD { + return Err(SaplingError::InvalidInput(format!( + "Output {} below dust threshold: {} zatoshis (min {} zatoshis)", + i, amount, SHIELDED_DUST_THRESHOLD + ))); + } + } + + let mut input_total: u64 = 0; + for (i, &amount) in input_amounts.iter().enumerate() { + input_total = input_total.checked_add(amount).ok_or_else(|| { + SaplingError::InvalidInput(format!("Input total overflow at input {}", i)) + })?; + + if amount > PIVX_MAX_SUPPLY { + return Err(SaplingError::InvalidInput(format!( + "Input {} exceeds max supply: {} > {}", + i, amount, PIVX_MAX_SUPPLY + ))); + } + } + + let mut output_total: u64 = 0; + for (i, &amount) in output_amounts.iter().enumerate() { + output_total = output_total.checked_add(amount).ok_or_else(|| { + SaplingError::InvalidInput(format!("Output total overflow at output {}", i)) + })?; + + if amount > PIVX_MAX_SUPPLY { + return Err(SaplingError::InvalidInput(format!( + "Output {} exceeds max supply: {} > {}", + i, amount, PIVX_MAX_SUPPLY + ))); + } + } + + if input_total > PIVX_MAX_SUPPLY { + return Err(SaplingError::InvalidInput(format!( + "Input total exceeds max supply: {} > {}", + input_total, PIVX_MAX_SUPPLY + ))); + } + + if output_total > PIVX_MAX_SUPPLY { + return Err(SaplingError::InvalidInput(format!( + "Output total exceeds max supply: {} > {}", + output_total, PIVX_MAX_SUPPLY + ))); + } + + // Verify inputs cover outputs plus fee. The builder adds any remaining + // value as a shielded change output after this validation. + let expected_total = output_total + .checked_add(fee) + .ok_or_else(|| SaplingError::InvalidInput("Output + fee overflow".into()))?; + + if input_total < expected_total { + return Err(SaplingError::InvalidInput(format!( + "Insufficient funds: inputs={}, outputs+fee={}", + input_total, expected_total + ))); + } + + Ok(()) +} + +/// Validate amounts for a transaction that may pay both shielded and +/// transparent outputs (z-to-z and z-to-t routes). +/// +/// Shielded outputs use the PIVX shielded dust threshold; transparent +/// outputs use the transparent dust threshold; inputs must cover the +/// combined outputs plus fee. +pub fn validate_route_transaction_amounts( + input_amounts: &[u64], + shielded_output_amounts: &[u64], + transparent_output_amounts: &[u64], + fee: u64, +) -> Result<(), SaplingError> { + if shielded_output_amounts.is_empty() && transparent_output_amounts.is_empty() { + return Err(SaplingError::InvalidInput("No outputs provided".into())); + } + + for (i, &amount) in transparent_output_amounts.iter().enumerate() { + if amount < TRANSPARENT_DUST_THRESHOLD { + return Err(SaplingError::InvalidInput(format!( + "Transparent output {} below dust threshold: {} zatoshis (min {} zatoshis)", + i, amount, TRANSPARENT_DUST_THRESHOLD + ))); + } + if amount > PIVX_MAX_SUPPLY { + return Err(SaplingError::InvalidInput(format!( + "Transparent output {} exceeds max supply: {} > {}", + i, amount, PIVX_MAX_SUPPLY + ))); + } + } + + let mut transparent_total: u64 = 0; + for &amount in transparent_output_amounts { + transparent_total = transparent_total.checked_add(amount).ok_or_else(|| { + SaplingError::InvalidInput("Transparent output total overflow".into()) + })?; + } + + // Shielded outputs, inputs, and the shielded-side balance reuse the + // existing validator, then the combined balance including transparent + // outputs is enforced on top. + validate_transaction_amounts( + input_amounts, + shielded_output_amounts, + fee.checked_add(transparent_total) + .ok_or_else(|| SaplingError::InvalidInput("Output + fee overflow".into()))?, + )?; + + Ok(()) +} + +/// PIVX Sapling activation height. +pub const PIVX_SAPLING_ACTIVATION: u32 = 2_700_500; +pub const PIVX_TESTNET_SAPLING_ACTIVATION: u32 = 201; + +/// PIVX mainnet consensus parameters. +#[derive(Clone, Copy, Debug)] +pub struct PivxMainnet; + +impl Parameters for PivxMainnet { + fn network_type(&self) -> NetworkType { + NetworkType::Main + } + + fn activation_height(&self, nu: NetworkUpgrade) -> Option { + match nu { + NetworkUpgrade::Sapling => Some(BlockHeight::from_u32(PIVX_SAPLING_ACTIVATION)), + _ => None, + } + } +} + +/// PIVX testnet consensus parameters. +#[derive(Clone, Copy, Debug)] +pub struct PivxTestnet; + +impl Parameters for PivxTestnet { + fn network_type(&self) -> NetworkType { + NetworkType::Test + } + + fn activation_height(&self, nu: NetworkUpgrade) -> Option { + match nu { + NetworkUpgrade::Sapling => Some(BlockHeight::from_u32(PIVX_TESTNET_SAPLING_ACTIVATION)), + _ => None, + } + } +} + +/// Transaction output destination. +#[derive(Clone, Debug)] +pub enum TransactionOutput { + /// Shielded output to a Sapling address. + Shielded { + address: PaymentAddress, + amount: u64, + memo: Option<[u8; 512]>, + }, +} + +/// Options for building a transaction. +#[derive(Clone, Debug)] +pub struct TransactionOptions { + /// Target height for the transaction. + pub target_height: u32, + /// Fee in zatoshis. + pub fee: u64, + /// Outputs to create. + pub outputs: Vec, + /// Whether this is testnet. + pub is_testnet: bool, +} + +/// Built transaction ready for broadcast. +#[derive(Clone, Debug)] +pub struct BuiltTransaction { + /// Serialized transaction bytes. + pub raw_tx: Vec, + /// Transaction ID (hash). + pub txid: [u8; 32], + /// Fee paid. + pub fee: u64, +} + +/// Sapling transaction builder. +pub struct TransactionBuilder { + esk: ExtendedSpendingKey, + dfvk: DiversifiableFullViewingKey, + is_testnet: bool, +} + +impl TransactionBuilder { + pub fn new( + esk: ExtendedSpendingKey, + dfvk: DiversifiableFullViewingKey, + is_testnet: bool, + ) -> Self { + Self { + esk, + dfvk, + is_testnet, + } + } + + pub fn extended_spending_key(&self) -> &ExtendedSpendingKey { + &self.esk + } + + pub fn dfvk(&self) -> &DiversifiableFullViewingKey { + &self.dfvk + } + + pub fn is_testnet(&self) -> bool { + self.is_testnet + } + + pub fn validate_inputs( + &self, + notes: &[SpendableNote], + merkle_paths: &[MerklePath], + options: &TransactionOptions, + ) -> SaplingResult<()> { + if notes.len() != merkle_paths.len() { + return Err(SaplingError::InvalidInput( + "notes and paths length mismatch".into(), + )); + } + + let input_total: u64 = notes.iter().map(|n| n.value()).sum(); + let output_total: u64 = options + .outputs + .iter() + .map(|o| match o { + TransactionOutput::Shielded { amount, .. } => *amount, + }) + .sum(); + + if input_total < output_total + options.fee { + return Err(SaplingError::InsufficientFunds); + } + + Ok(()) + } + + pub fn calculate_change(notes: &[SpendableNote], options: &TransactionOptions) -> u64 { + let input_total: u64 = notes.iter().map(|n| n.value()).sum(); + let output_total: u64 = options + .outputs + .iter() + .map(|o| match o { + TransactionOutput::Shielded { amount, .. } => *amount, + }) + .sum(); + + input_total.saturating_sub(output_total + options.fee) + } + + pub fn change_address(&self) -> PaymentAddress { + let (_, addr) = self.dfvk.default_address(); + addr + } + + /// Build a z-to-z Sapling transaction. Thin wrapper over + /// build_route_transaction with no transparent outputs. + pub fn build_transaction( + &self, + notes: Vec, + merkle_paths: Vec, + anchor: Anchor, + outputs: Vec<(PaymentAddress, u64, Option<[u8; 512]>)>, + fee: u64, + ) -> SaplingResult { + self.build_route_transaction(notes, merkle_paths, anchor, outputs, Vec::new(), fee) + } + + /// Build a transparent-to-shielded (t-to-z, shield) transaction. + /// + /// Spends P2PKH UTXOs into Sapling outputs, with optional transparent + /// change. Amounts must balance exactly: inputs = shielded outputs + + /// change + fee; the caller plans fees and dust absorption. + pub fn build_shield_transaction( + &self, + inputs: Vec, + outputs: Vec<(PaymentAddress, u64, Option<[u8; 512]>)>, + transparent_change: Option, + fee: u64, + ) -> SaplingResult { + if inputs.is_empty() { + return Err(SaplingError::InvalidInput("No input UTXOs provided".into())); + } + if outputs.is_empty() { + return Err(SaplingError::InvalidInput("No outputs provided".into())); + } + if !prover::is_prover_initialized() { + return Err(SaplingError::ProverNotInitialized); + } + + let input_amounts: Vec = inputs.iter().map(|input| input.value).collect(); + let output_amounts: Vec = outputs.iter().map(|(_, amount, _)| *amount).collect(); + let change_amounts: Vec = transparent_change + .iter() + .map(|output| output.value) + .collect(); + validate_route_transaction_amounts( + &input_amounts, + &output_amounts, + &change_amounts, + fee, + )?; + + let input_total: u64 = input_amounts.iter().sum(); + let output_total: u64 = + output_amounts.iter().sum::() + change_amounts.iter().sum::(); + if input_total != output_total + fee { + return Err(SaplingError::InvalidInput(format!( + "Shield transaction amounts must balance exactly: inputs={}, outputs+fee={}", + input_total, + output_total + fee + ))); + } + + // outputs-only shield, no spends. keep bundle_required false: with true, + // num_spends pads to max(0,1)=1 and adds a dummy spend anchored to + // empty_tree() that the node rejects (bad-txns-shielded-requirements-not-met). + let bundle_type = BundleType::Transactional { + bundle_required: false, + }; + let mut builder = SaplingBuilder::new( + Zip212Enforcement::Off, + bundle_type, + Anchor::empty_tree(), + ); + let ovk = self.dfvk.to_ovk(Scope::External); + for (address, amount, memo) in outputs { + builder + .add_output(Some(ovk.clone()), address, NoteValue::from_raw(amount), memo) + .map_err(|_| SaplingError::TransactionBuild)?; + } + + let mut rng = OsRng; + let extsks: &[ExtendedSpendingKey] = &[]; + let build_result = builder + .build::(extsks, &mut rng) + .map_err(|e| SaplingError::ProofError(format!("Bundle build failed: {:?}", e)))?; + let (unproven_bundle, _sapling_meta) = match build_result { + Some(b) => b, + None => return Err(SaplingError::TransactionBuild), + }; + + let prover_guard = prover::get_prover()?; + let local_prover = prover_guard + .as_ref() + .ok_or(SaplingError::ProverNotInitialized)?; + let proven_bundle = + unproven_bundle.create_proofs(local_prover, local_prover, &mut rng, ()); + + // Binding signature and every transparent input signature share the + // common sighash legs over the real vin/vout and the Sapling bundle. + let change_outputs: Vec = + transparent_change.iter().cloned().collect(); + let sighash_state = + self.sighash_common_state(&proven_bundle, &inputs, &change_outputs); + let binding_sighash = Self::finalize_binding_sighash(&sighash_state); + + let authorized_bundle = proven_bundle + .apply_signatures(&mut rng, binding_sighash, &[]) + .map_err(|e| SaplingError::ProofError(format!("Signing failed: {:?}", e)))?; + self.verify_authorized_bundle(&authorized_bundle, binding_sighash, local_prover)?; + + let script_sigs: Vec> = inputs + .iter() + .map(|input| { + input.script_sig(Self::finalize_input_sighash(&sighash_state, input)) + }) + .collect(); + + let raw_tx = self.serialize_pivx_transaction( + &authorized_bundle, + &inputs, + &script_sigs, + &change_outputs, + )?; + + use sha2::{Digest, Sha256}; + let first_hash = Sha256::digest(&raw_tx); + let txid_bytes = Sha256::digest(first_hash); + let mut txid = [0u8; 32]; + txid.copy_from_slice(&txid_bytes); + txid.reverse(); + + Ok(BuiltTransaction { raw_tx, txid, fee }) + } + + /// Build a shielded transaction that may also pay transparent outputs + /// (z-to-z when `transparent_outputs` is empty, z-to-t otherwise). + /// + /// Change always returns to the wallet's own shielded change address, so + /// deshielding only exposes the explicitly requested payment value. + pub fn build_route_transaction( + &self, + notes: Vec, + merkle_paths: Vec, + anchor: Anchor, + outputs: Vec<(PaymentAddress, u64, Option<[u8; 512]>)>, + transparent_outputs: Vec, + fee: u64, + ) -> SaplingResult { + if notes.len() != merkle_paths.len() { + return Err(SaplingError::InvalidInput( + "Notes and merkle paths count mismatch".into(), + )); + } + + if notes.is_empty() { + return Err(SaplingError::InvalidInput("No input notes provided".into())); + } + + if outputs.is_empty() && transparent_outputs.is_empty() { + return Err(SaplingError::InvalidInput("No outputs provided".into())); + } + + // SECURITY: every spend's witness must recompute to the anchor being + // signed. This runs before any proving work so a server-supplied + // witness that is not anchored to the selected root can never enter + // proof construction. + for (idx, (note, path)) in notes.iter().zip(merkle_paths.iter()).enumerate() { + let witness_root = Anchor::from(path.root(Node::from_cmu(¬e.note.cmu()))); + if witness_root.to_bytes() != anchor.to_bytes() { + return Err(SaplingError::InvalidInput(format!( + "witness_anchor_mismatch: spend {} witness root does not match the spend anchor", + idx + ))); + } + } + + if !prover::is_prover_initialized() { + return Err(SaplingError::ProverNotInitialized); + } + + let input_amounts: Vec = notes.iter().map(|n| n.value()).collect(); + let output_amounts: Vec = outputs.iter().map(|(_, amount, _)| *amount).collect(); + let transparent_amounts: Vec = transparent_outputs + .iter() + .map(|output| output.value) + .collect(); + validate_route_transaction_amounts( + &input_amounts, + &output_amounts, + &transparent_amounts, + fee, + )?; + + // Calculate totals (already validated above, but needed for change) + let input_total: u64 = input_amounts.iter().sum(); + let output_total: u64 = + output_amounts.iter().sum::() + transparent_amounts.iter().sum::(); + + let change = input_total - output_total - fee; + + if change > 0 && change <= SHIELDED_DUST_THRESHOLD { + return Err(SaplingError::InvalidInput(format!( + "Change amount {} below dust threshold {}", + change, SHIELDED_DUST_THRESHOLD + ))); + } + + // PIVX is pre-ZIP-212, so Zip212Enforcement::Off. + let bundle_type = BundleType::Transactional { + bundle_required: true, + }; + + let mut builder = SaplingBuilder::new( + Zip212Enforcement::Off, + bundle_type, + anchor, + ); + + let fvk = self.dfvk.fvk(); + + for (note, path) in notes.iter().zip(merkle_paths.into_iter()) { + builder + .add_spend(fvk.clone(), note.note.clone(), path) + .map_err(|_e| SaplingError::TransactionBuild)?; + } + + // OVK lets the sender decrypt its own outputs later. + let ovk = self.dfvk.to_ovk(Scope::External); + + for (address, amount, memo) in outputs { + builder + .add_output( + Some(ovk.clone()), + address, + NoteValue::from_raw(amount), + memo, + ) + .map_err(|_| SaplingError::TransactionBuild)?; + } + + if change > 0 { + let change_address = self.change_address(); + builder + .add_output( + Some(ovk.clone()), + change_address, + NoteValue::from_raw(change), + None, + ) + .map_err(|_| SaplingError::TransactionBuild)?; + } + + let mut rng = OsRng; + let extsks = &[self.esk.clone()]; + + let build_result = builder + .build::(extsks, &mut rng) + .map_err(|e| SaplingError::ProofError(format!("Bundle build failed: {:?}", e)))?; + + let (unproven_bundle, _sapling_meta) = match build_result { + Some(b) => b, + None => return Err(SaplingError::TransactionBuild), + }; + + let prover_guard = prover::get_prover()?; + let local_prover = prover_guard + .as_ref() + .ok_or(SaplingError::ProverNotInitialized)?; + + let proven_bundle = unproven_bundle.create_proofs( + local_prover, + local_prover, + &mut rng, + (), // no progress notification + ); + + let ask = self.esk.expsk.ask.clone(); + let sighash = self.compute_sighash(&proven_bundle, &transparent_outputs); + + let authorized_bundle = proven_bundle + .apply_signatures(&mut rng, sighash, &[ask]) + .map_err(|e| SaplingError::ProofError(format!("Signing failed: {:?}", e)))?; + + self.verify_authorized_bundle(&authorized_bundle, sighash, local_prover)?; + + let raw_tx = + self.serialize_sapling_transaction(&authorized_bundle, &transparent_outputs)?; + + use sha2::{Digest, Sha256}; + let first_hash = Sha256::digest(&raw_tx); + let txid_bytes = Sha256::digest(&first_hash); + let mut txid = [0u8; 32]; + txid.copy_from_slice(&txid_bytes); + txid.reverse(); // txid is displayed in reverse byte order + + Ok(BuiltTransaction { raw_tx, txid, fee }) + } + + /// Serialize a Sapling bundle into PIVX transaction format. + /// + /// PIVX transaction structure for Sapling: + /// - Version: 4 bytes (version 3 with overwinter flag) + /// - Version group ID: 4 bytes + /// - Transparent inputs: varint count + inputs + /// - Transparent outputs: varint count + outputs + /// - Lock time: 4 bytes + /// - Expiry height: 4 bytes + /// - Value balance: 8 bytes (signed) + /// - Sapling spends: varint count + serialized spends + /// - Sapling outputs: varint count + serialized outputs + /// - Binding signature: 64 bytes + fn serialize_sapling_transaction( + &self, + bundle: &sapling::Bundle, + transparent_outputs: &[TransparentOutput], + ) -> SaplingResult> { + self.serialize_pivx_transaction(bundle, &[], &[], transparent_outputs) + } + + fn serialize_pivx_transaction( + &self, + bundle: &sapling::Bundle, + transparent_inputs: &[TransparentInput], + script_sigs: &[Vec], + transparent_outputs: &[TransparentOutput], + ) -> SaplingResult> { + use std::io::Write; + + let mut tx = Vec::new(); + + // PIVX Transaction Header (4 bytes total): + // - nVersion (2 bytes, int16_t): 3 for Sapling + // - nType (2 bytes, int16_t): 0 for Normal transaction + // + // PIVX does NOT use Zcash's transaction format: + // Zcash: version (4 bytes with overwinter bit) + version group ID (4 bytes) = 8 bytes + // PIVX: nVersion (2 bytes) + nType (2 bytes) = 4 bytes + // + // Reference: PIVX Core src/primitives/transaction.h + // class CTransaction { + // const int16_t nVersion; // 1=Legacy, 3=Sapling + // const int16_t nType; // 0=Normal, 1+=Special (ProReg, etc.) + // }; + // + // Verified against: PIVX Core commit 0cbf7b89 (December 2025) + tx.write_all(&3i16.to_le_bytes()) + .map_err(|_| SaplingError::TransactionBuild)?; // nVersion = 3 (Sapling) + tx.write_all(&0i16.to_le_bytes()) + .map_err(|_| SaplingError::TransactionBuild)?; // nType = 0 (Normal) + + // Transparent inputs (empty for shielded-only routes, the signed + // UTXOs for t-to-z), serialized exactly as PIVX Core serializes vin. + if transparent_inputs.len() != script_sigs.len() { + return Err(SaplingError::TransactionBuild); + } + { + let mut vin = Vec::new(); + write_compact_size(&mut vin, transparent_inputs.len() as u64); + for (input, script_sig) in transparent_inputs.iter().zip(script_sigs) { + input.serialize_prevout(&mut vin); + write_compact_size(&mut vin, script_sig.len() as u64); + vin.extend_from_slice(script_sig); + vin.extend_from_slice(&TransparentInput::SEQUENCE_FINAL.to_le_bytes()); + } + tx.write_all(&vin) + .map_err(|_| SaplingError::TransactionBuild)?; + } + + // Transparent outputs (empty for z-to-z, the deshield payments for + // z-to-t, change for t-to-z), serialized exactly as PIVX Core + // serializes vout + serialize_transparent_outputs(&mut tx, transparent_outputs); + + // Lock time (4 bytes, 0 = immediate) + tx.write_all(&0u32.to_le_bytes()) + .map_err(|_| SaplingError::TransactionBuild)?; + + // CRITICAL: PIVX does NOT serialize expiry height (Zcash-specific feature removed) + // Reference: PIVX Core src/primitives/transaction.h SerializeTransaction() + // s << tx.nVersion; + // s << tx.nType; + // s << tx.vin; + // s << tx.vout; + // s << tx.nLockTime; + // if (tx.isSaplingVersion()) { + // s << tx.sapData; // Goes DIRECTLY to Sapling data, no expiry height! + // } + // + // no expiry-height field in PIVX (unlike Zcash) + + // Sapling data optional marker. + // + // PIVX serializes Sapling payloads as Optional after + // nLockTime. Normal transactions do not serialize extraPayload, but the + // sapData presence byte is required before valueBalance. + tx.push(0x01); + + // Value balance (8 bytes, signed little endian) + // This is the net value flow: sum(spend values) - sum(output values) + // A positive value means value is flowing from shielded to transparent + // For a pure shielded tx, this equals the fee + let value_balance: i64 = *bundle.value_balance(); + tx.write_all(&value_balance.to_le_bytes()) + .map_err(|_| SaplingError::TransactionBuild)?; + + // Sapling spends + let spends = bundle.shielded_spends(); + self.write_varint(&mut tx, spends.len() as u64); + for spend in spends { + // cv (32 bytes): value commitment + tx.write_all(&spend.cv().to_bytes()) + .map_err(|_| SaplingError::TransactionBuild)?; + // anchor (32 bytes) + tx.write_all(&spend.anchor().to_bytes()) + .map_err(|_| SaplingError::TransactionBuild)?; + // nullifier (32 bytes) + tx.write_all(&spend.nullifier().0) + .map_err(|_| SaplingError::TransactionBuild)?; + // rk (32 bytes): randomized public key + let rk_bytes: [u8; 32] = spend.rk().clone().into(); + tx.write_all(&rk_bytes) + .map_err(|_| SaplingError::TransactionBuild)?; + // zkproof (192 bytes for Groth16) + tx.write_all(spend.zkproof()) + .map_err(|_| SaplingError::TransactionBuild)?; + // spend_auth_sig (64 bytes) + tx.write_all(&<[u8; 64]>::from(*spend.spend_auth_sig())) + .map_err(|_| SaplingError::TransactionBuild)?; + } + + // Sapling outputs + let outputs = bundle.shielded_outputs(); + self.write_varint(&mut tx, outputs.len() as u64); + for output in outputs { + // cv (32 bytes): value commitment + tx.write_all(&output.cv().to_bytes()) + .map_err(|_| SaplingError::TransactionBuild)?; + // cmu (32 bytes): note commitment + tx.write_all(&output.cmu().to_bytes()) + .map_err(|_| SaplingError::TransactionBuild)?; + // ephemeral_key (32 bytes) + tx.write_all(output.ephemeral_key().as_ref()) + .map_err(|_| SaplingError::TransactionBuild)?; + // enc_ciphertext (580 bytes) + tx.write_all(output.enc_ciphertext()) + .map_err(|_| SaplingError::TransactionBuild)?; + // out_ciphertext (80 bytes) + tx.write_all(output.out_ciphertext()) + .map_err(|_| SaplingError::TransactionBuild)?; + // zkproof (192 bytes) + tx.write_all(output.zkproof()) + .map_err(|_| SaplingError::TransactionBuild)?; + } + + // Binding signature (64 bytes) + let binding_sig = bundle.authorization().binding_sig; + tx.write_all(&<[u8; 64]>::from(binding_sig)) + .map_err(|_| SaplingError::TransactionBuild)?; + + Ok(tx) + } + + fn verify_authorized_bundle( + &self, + bundle: &sapling::Bundle, + sighash: [u8; 32], + prover: &zcash_proofs::prover::LocalTxProver, + ) -> SaplingResult<()> { + use bellman::groth16::Proof; + use bls12_381::Bls12; + use group::GroupEncoding; + + let (spend_vk, output_vk) = prover.verifying_keys(); + let spend_pvk = spend_vk.prepare(); + let output_pvk = output_vk.prepare(); + let mut ctx = SaplingVerificationContext::new(); + + for (idx, spend) in bundle.shielded_spends().iter().enumerate() { + if spend.rk().verify(&sighash, spend.spend_auth_sig()).is_err() { + return Err(SaplingError::ProofError(format!( + "Local Sapling spend auth signature verification failed for spend {}", + idx + ))); + } + + let proof = Proof::::read(&spend.zkproof()[..]).map_err(|e| { + SaplingError::ProofError(format!( + "Local Sapling spend proof parse failed for spend {}: {}", + idx, e + )) + })?; + + if !ctx.check_spend( + spend.cv(), + *spend.anchor(), + &spend.nullifier().0, + *spend.rk(), + &sighash, + *spend.spend_auth_sig(), + proof, + &spend_pvk, + ) { + return Err(SaplingError::ProofError(format!( + "Local Sapling spend proof verification failed for spend {}", + idx + ))); + } + } + + for (idx, output) in bundle.shielded_outputs().iter().enumerate() { + let proof = Proof::::read(&output.zkproof()[..]).map_err(|e| { + SaplingError::ProofError(format!( + "Local Sapling output proof parse failed for output {}: {}", + idx, e + )) + })?; + let epk_bytes: [u8; 32] = + output + .ephemeral_key() + .as_ref() + .try_into() + .map_err(|_| { + SaplingError::ProofError(format!( + "Local Sapling output ephemeral key length invalid for output {}", + idx + )) + })?; + let epk = jubjub::ExtendedPoint::from_bytes(&epk_bytes) + .into_option() + .ok_or_else(|| { + SaplingError::ProofError(format!( + "Local Sapling output ephemeral key parse failed for output {}", + idx + )) + })?; + + if !ctx.check_output(output.cv(), *output.cmu(), epk, proof, &output_pvk) { + return Err(SaplingError::ProofError(format!( + "Local Sapling output proof verification failed for output {}", + idx + ))); + } + } + + if !ctx.final_check( + *bundle.value_balance(), + &sighash, + bundle.authorization().binding_sig, + ) { + return Err(SaplingError::ProofError( + "Local Sapling binding signature verification failed".into(), + )); + } + + Ok(()) + } + + /// Write a variable-length integer (Bitcoin-style varint). + fn write_varint(&self, buf: &mut Vec, n: u64) { + if n < 0xfd { + buf.push(n as u8); + } else if n <= 0xffff { + buf.push(0xfd); + buf.extend_from_slice(&(n as u16).to_le_bytes()); + } else if n <= 0xffffffff { + buf.push(0xfe); + buf.extend_from_slice(&(n as u32).to_le_bytes()); + } else { + buf.push(0xff); + buf.extend_from_slice(&n.to_le_bytes()); + } + } + + /// Compute sighash for Sapling transaction signing. + /// + /// PIVX uses BLAKE2b-256 with personalization: "PIVXSigHash" + branch ID (0). + /// The sighash format is based on ZIP 243 but adapted for PIVX's transaction structure. + /// + /// Reference: PIVX Core src/script/interpreter.cpp SignatureHash() + /// ss << txTo.nVersion; // int16_t (2 bytes) + /// ss << txTo.nType; // int16_t (2 bytes) + /// ss << hashPrevouts; + /// ss << hashSequence; + /// ss << hashOutputs; + /// ss << hashShieldedSpends; + /// ss << hashShieldedOutputs; + /// ss << txTo.sapData->valueBalance; + /// // ... input being signed, locktime, hashtype + fn compute_sighash( + &self, + bundle: &sapling::Bundle< + sapling::builder::InProgress, + i64, + >, + transparent_outputs: &[TransparentOutput], + ) -> [u8; 32] { + let state = self.sighash_common_state(bundle, &[], transparent_outputs); + Self::finalize_binding_sighash(&state) + } + + /// Finalize the transaction-level (binding/spend-auth) sighash: the + /// common legs followed by locktime and SIGHASH_ALL, with no input leg + /// (PIVX Core's NOT_AN_INPUT case). + fn finalize_binding_sighash(state: &blake2b_simd::State) -> [u8; 32] { + use std::io::Write; + let mut hasher = state.clone(); + hasher.write_all(&0u32.to_le_bytes()).unwrap(); // nLockTime + hasher.write_all(&1u32.to_le_bytes()).unwrap(); // SIGHASH_ALL + let result = hasher.finalize(); + let mut sighash = [0u8; 32]; + sighash.copy_from_slice(result.as_bytes()); + sighash + } + + /// Finalize the per-input sighash for a transparent input: the common + /// legs, then prevout + scriptCode + amount + nSequence, then locktime + /// and SIGHASH_ALL. Reference: PIVX Core interpreter.cpp SignatureHash + /// (the `nIn != NOT_AN_INPUT` branch). + fn finalize_input_sighash( + state: &blake2b_simd::State, + input: &TransparentInput, + ) -> [u8; 32] { + use std::io::Write; + let mut hasher = state.clone(); + let mut input_leg = Vec::with_capacity(36 + 1 + input.script_pubkey.len() + 12); + input.serialize_prevout(&mut input_leg); + // scriptCode for P2PKH is the UTXO's scriptPubKey, serialized like a + // CScript (compact size + bytes). + write_compact_size(&mut input_leg, input.script_pubkey.len() as u64); + input_leg.extend_from_slice(&input.script_pubkey); + input_leg.extend_from_slice(&input.value.to_le_bytes()); + input_leg.extend_from_slice(&TransparentInput::SEQUENCE_FINAL.to_le_bytes()); + hasher.write_all(&input_leg).unwrap(); + hasher.write_all(&0u32.to_le_bytes()).unwrap(); // nLockTime + hasher.write_all(&1u32.to_le_bytes()).unwrap(); // SIGHASH_ALL + let result = hasher.finalize(); + let mut sighash = [0u8; 32]; + sighash.copy_from_slice(result.as_bytes()); + sighash + } + + /// Build the sighash legs shared by the binding signature and every + /// transparent input signature: header, hashPrevouts, hashSequence, + /// hashOutputs, shielded spend/output legs, and value balance. + fn sighash_common_state( + &self, + bundle: &sapling::Bundle< + sapling::builder::InProgress, + i64, + >, + transparent_inputs: &[TransparentInput], + transparent_outputs: &[TransparentOutput], + ) -> blake2b_simd::State { + use blake2b_simd::Params; + use std::io::Write; + + // PIVX Sapling personalization: "PIVXSigHash" + padding + branch_id (4 bytes) + // Verified against PIVX Core: src/script/interpreter.cpp:1228-1234 + let personalization = pivx_sighash_personalization(); + + let mut hasher = Params::new() + .hash_length(32) + .personal(&personalization) + .to_state(); + + // Hash transaction header data + // PIVX format: nVersion (2 bytes) + nType (2 bytes) + // Verified against PIVX Core: interpreter.cpp:1238-1240 + // ss << txTo.nVersion; // int16_t + // ss << txTo.nType; // int16_t + hasher.write_all(&3i16.to_le_bytes()).unwrap(); // nVersion = 3 (Sapling) + hasher.write_all(&0i16.to_le_bytes()).unwrap(); // nType = 0 (Normal) + + // Hash transparent prevouts/sequence over the real vin (empty for + // shielded-only transactions). For SIGHASH_ALL with no transparent + // inputs, PIVX commits to the personalized BLAKE2b hash of the empty + // vector, not 32 zero bytes. + let mut prevouts_bytes = Vec::with_capacity(transparent_inputs.len() * 36); + let mut sequence_bytes = Vec::with_capacity(transparent_inputs.len() * 4); + for input in transparent_inputs { + input.serialize_prevout(&mut prevouts_bytes); + sequence_bytes + .extend_from_slice(&TransparentInput::SEQUENCE_FINAL.to_le_bytes()); + } + hasher + .write_all( + Params::new() + .hash_length(32) + .personal(b"PIVXPrevoutHash") + .hash(&prevouts_bytes) + .as_bytes(), + ) + .unwrap(); + hasher + .write_all( + Params::new() + .hash_length(32) + .personal(b"PIVXSequencHash") + .hash(&sequence_bytes) + .as_bytes(), + ) + .unwrap(); + // hashOutputs commits to the serialized transparent vout vector + // (empty for z-to-z, the deshield payments for z-to-t). + // Reference: PIVX Core interpreter.cpp GetOutputsHash(). + let mut vout_bytes = Vec::new(); + for output in transparent_outputs { + output.serialize_into(&mut vout_bytes); + } + hasher + .write_all( + Params::new() + .hash_length(32) + .personal(b"PIVXOutputsHash") + .hash(&vout_bytes) + .as_bytes(), + ) + .unwrap(); + + // Hash shielded spends. PIVX Core only computes the personalized + // hash when the spend vector is non-empty; an empty vector commits + // to 32 zero bytes (default-constructed uint256), unlike the + // transparent legs above. Reference: interpreter.cpp SignatureHash. + if bundle.shielded_spends().is_empty() { + hasher.write_all(&[0u8; 32]).unwrap(); + } else { + let mut spend_hash = Params::new() + .hash_length(32) + .personal(b"PIVXSSpendsHash") + .to_state(); + for spend in bundle.shielded_spends() { + spend_hash.write_all(&spend.cv().to_bytes()).unwrap(); + spend_hash.write_all(&spend.anchor().to_bytes()).unwrap(); + spend_hash.write_all(&spend.nullifier().0).unwrap(); + let rk_bytes: [u8; 32] = spend.rk().clone().into(); + spend_hash.write_all(&rk_bytes).unwrap(); + spend_hash.write_all(spend.zkproof()).unwrap(); + } + hasher.write_all(spend_hash.finalize().as_bytes()).unwrap(); + } + + // Hash shielded outputs, with the same empty-vector zero-bytes rule. + if bundle.shielded_outputs().is_empty() { + hasher.write_all(&[0u8; 32]).unwrap(); + } else { + let mut output_hash = Params::new() + .hash_length(32) + .personal(b"PIVXSOutputHash") + .to_state(); + for output in bundle.shielded_outputs() { + output_hash.write_all(&output.cv().to_bytes()).unwrap(); + output_hash.write_all(&output.cmu().to_bytes()).unwrap(); + output_hash + .write_all(output.ephemeral_key().as_ref()) + .unwrap(); + output_hash.write_all(output.enc_ciphertext()).unwrap(); + output_hash.write_all(output.out_ciphertext()).unwrap(); + output_hash.write_all(output.zkproof()).unwrap(); + } + hasher.write_all(output_hash.finalize().as_bytes()).unwrap(); + } + + // Value balance + let value_balance: i64 = *bundle.value_balance(); + hasher.write_all(&value_balance.to_le_bytes()).unwrap(); + + hasher + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pivx_params() { + let params = PivxMainnet; + assert_eq!(params.network_type(), NetworkType::Main); + + let activation = params.activation_height(NetworkUpgrade::Sapling); + assert!(activation.is_some()); + assert_eq!( + activation.unwrap(), + BlockHeight::from_u32(PIVX_SAPLING_ACTIVATION) + ); + } + + #[test] + fn test_pivx_testnet_params() { + let params = PivxTestnet; + assert_eq!(params.network_type(), NetworkType::Test); + } + + #[test] + fn test_sighash_personalization() { + // Verify we use PIVX-specific personalization, not Zcash's + // Reference: PIVX Core src/script/interpreter.cpp:1228-1234 + let mut personalization = [0u8; 16]; + // "PIVXSigHash" is 11 bytes, padded with null to 12 bytes + let pivx_str = b"PIVXSigHash"; + personalization[..pivx_str.len()].copy_from_slice(pivx_str); + personalization[12..16].copy_from_slice(&0u32.to_le_bytes()); + + assert_eq!( + &personalization[..11], + b"PIVXSigHash", + "Sighash personalization must start with 'PIVXSigHash' for PIVX consensus" + ); + + assert_eq!(personalization[11], 0, "12th byte should be null padding"); + + // Branch ID is 0 (not Zcash's 0x03C48270). + let branch_id = u32::from_le_bytes([ + personalization[12], + personalization[13], + personalization[14], + personalization[15], + ]); + assert_eq!(branch_id, 0, "Branch ID must be 0 for PIVX consensus"); + } + + #[test] + fn test_transaction_version() { + // PIVX uses a different transaction header format than Zcash + // PIVX: nVersion (2 bytes, int16_t) + nType (2 bytes, int16_t) + // Zcash: version with overwinter bit (4 bytes) + version group ID (4 bytes) + // + // Reference: PIVX Core src/primitives/transaction.h + // class CTransaction { + // const int16_t nVersion; // 1=Legacy, 3=Sapling + // const int16_t nType; // 0=Normal, 1+=Special + // }; + + let n_version: i16 = 3; // Sapling + let n_type: i16 = 0; // Normal transaction + + assert_eq!(n_version, 3, "Sapling transactions must use nVersion = 3"); + assert_eq!(n_type, 0, "Normal transactions must use nType = 0"); + + let mut header = Vec::new(); + header.extend_from_slice(&n_version.to_le_bytes()); // 2 bytes + header.extend_from_slice(&n_type.to_le_bytes()); // 2 bytes + assert_eq!( + header.len(), + 4, + "Transaction header must be exactly 4 bytes" + ); + + assert_eq!( + header, + vec![0x03, 0x00, 0x00, 0x00], + "Header must be [0x03, 0x00, 0x00, 0x00] for Sapling Normal transaction" + ); + } + + #[test] + fn test_activation_heights() { + // Reference: PIVX Core src/chainparams.cpp:285 + let mainnet = PivxMainnet; + let mainnet_activation = mainnet.activation_height(NetworkUpgrade::Sapling); + assert_eq!( + mainnet_activation, + Some(BlockHeight::from_u32(2_700_500)), + "Mainnet Sapling activation must be block 2,700,500" + ); + + // Reference: PIVX Core src/chainparams.cpp:445 + let testnet = PivxTestnet; + let testnet_activation = testnet.activation_height(NetworkUpgrade::Sapling); + assert_eq!( + testnet_activation, + Some(BlockHeight::from_u32(201)), + "Testnet Sapling activation must be block 201" + ); + } + + #[test] + fn test_shielded_dust_threshold() { + assert_eq!(SHIELDED_DUST_THRESHOLD, 1_446_000); + assert!(validate_transaction_amounts(&[3_000_000], &[1_445_999], 1_554_001).is_err()); + assert!(validate_transaction_amounts(&[3_000_000], &[1_446_000], 1_554_000).is_ok()); + } + + fn base58check_encode(payload: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(Sha256::digest(payload)); + let mut data = payload.to_vec(); + data.extend_from_slice(&digest[..4]); + + let mut digits: Vec = Vec::new(); + for &byte in &data { + let mut carry = byte as u32; + for digit in digits.iter_mut() { + carry += (*digit as u32) << 8; + *digit = (carry % 58) as u8; + carry /= 58; + } + while carry > 0 { + digits.push((carry % 58) as u8); + carry /= 58; + } + } + for &byte in &data { + if byte == 0 { + digits.push(0); + } else { + break; + } + } + digits + .iter() + .rev() + .map(|&d| BASE58_ALPHABET[d as usize] as char) + .collect() + } + + #[test] + fn test_transparent_address_script_p2pkh() { + let hash: [u8; 20] = [0x11; 20]; + let mut payload = vec![PIVX_MAINNET_PUBKEY_PREFIX]; + payload.extend_from_slice(&hash); + let address = base58check_encode(&payload); + assert!(address.starts_with('D')); + + let script = script_pubkey_for_transparent_address(&address, false).unwrap(); + assert_eq!(script.len(), 25); + assert_eq!(&script[..3], &[0x76, 0xa9, 0x14]); + assert_eq!(&script[3..23], &hash); + assert_eq!(&script[23..], &[0x88, 0xac]); + + // Wrong network must be rejected. + assert!(script_pubkey_for_transparent_address(&address, true).is_err()); + } + + #[test] + fn test_transparent_address_script_p2sh_and_testnet() { + let hash: [u8; 20] = [0x22; 20]; + let mut payload = vec![PIVX_MAINNET_SCRIPT_PREFIX]; + payload.extend_from_slice(&hash); + let address = base58check_encode(&payload); + let script = script_pubkey_for_transparent_address(&address, false).unwrap(); + assert_eq!(script.len(), 23); + assert_eq!(&script[..2], &[0xa9, 0x14]); + assert_eq!(&script[2..22], &hash); + assert_eq!(script[22], 0x87); + + let mut testnet_payload = vec![PIVX_TESTNET_PUBKEY_PREFIX]; + testnet_payload.extend_from_slice(&hash); + let testnet_address = base58check_encode(&testnet_payload); + assert!(script_pubkey_for_transparent_address(&testnet_address, true).is_ok()); + assert!(script_pubkey_for_transparent_address(&testnet_address, false).is_err()); + } + + #[test] + fn test_transparent_address_rejects_corruption() { + let hash: [u8; 20] = [0x33; 20]; + let mut payload = vec![PIVX_MAINNET_PUBKEY_PREFIX]; + payload.extend_from_slice(&hash); + let address = base58check_encode(&payload); + + // Flip one character: checksum must fail. + let mut corrupted: Vec = address.chars().collect(); + let last = corrupted.len() - 1; + corrupted[last] = if corrupted[last] == '2' { '3' } else { '2' }; + let corrupted: String = corrupted.into_iter().collect(); + assert!(script_pubkey_for_transparent_address(&corrupted, false).is_err()); + + // Sapling bech32 addresses are not valid transparent addresses. + assert!(script_pubkey_for_transparent_address( + "ps1invalidnotbase58_0OIl", + false + ) + .is_err()); + } + + #[test] + fn test_transparent_output_serialization() { + let output = TransparentOutput { + value: 123_456_789, + script_pubkey: vec![0x76, 0xa9, 0x14, 0xaa, 0x88, 0xac], + }; + let mut buf = Vec::new(); + serialize_transparent_outputs(&mut buf, &[output]); + assert_eq!(buf[0], 1); // vout count + assert_eq!(&buf[1..9], &123_456_789u64.to_le_bytes()); + assert_eq!(buf[9], 6); // script length + assert_eq!(&buf[10..], &[0x76, 0xa9, 0x14, 0xaa, 0x88, 0xac]); + + let mut empty = Vec::new(); + serialize_transparent_outputs(&mut empty, &[]); + assert_eq!(empty, vec![0x00]); + } + + #[test] + fn test_route_amount_validation_transparent_dust() { + assert_eq!(TRANSPARENT_DUST_THRESHOLD, 5_460); + // Transparent output below transparent dust is rejected. + assert!( + validate_route_transaction_amounts(&[3_000_000], &[], &[5_459], 10_000).is_err() + ); + // Transparent output at the threshold is accepted (z-to-t). + assert!( + validate_route_transaction_amounts(&[3_000_000], &[], &[5_460], 10_000).is_ok() + ); + // Shielded outputs still use the shielded dust threshold. + assert!(validate_route_transaction_amounts(&[3_000_000], &[1_445_999], &[], 10_000) + .is_err()); + // Inputs must cover shielded + transparent outputs + fee. + assert!(validate_route_transaction_amounts( + &[1_000_000], + &[], + &[995_000], + 10_000 + ) + .is_err()); + // No outputs at all is invalid. + assert!(validate_route_transaction_amounts(&[3_000_000], &[], &[], 10_000).is_err()); + } + + #[test] + fn test_amount_validation_allows_builder_change() { + assert!(validate_transaction_amounts(&[20_000_000], &[12_000_000], 365_000).is_ok()); + assert!(validate_transaction_amounts(&[12_364_999], &[12_000_000], 365_000).is_err()); + } + + #[test] + fn large_deshield_is_not_rejected_as_fee_too_large() { + // z->t: the transparent output total is folded into the fee arg passed to + // validate_transaction_amounts. A deshield above 10 PIV used to trip a + // fabricated `fee > 1_000_000_000` cap even though the real fee is tiny. + // PIVX Core has no absolute fee cap; this must be accepted. + let amount: u64 = 2_000_000_000; // 20 PIV transparent output + let fee: u64 = 1_417_000; + assert!( + validate_route_transaction_amounts(&[amount + fee], &[], &[amount], fee).is_ok() + ); + } + + fn route_transaction_fixture() -> ( + TransactionBuilder, + Vec, + Vec, + Vec<(PaymentAddress, u64, Option<[u8; 512]>)>, + ) { + use sapling::{value::NoteValue, Note, Nullifier, Rseed}; + + let manager = + crate::keys::SaplingKeyManager::from_seed(&[7u8; 64], crate::types::Network::Mainnet) + .unwrap(); + let address = manager.default_address().unwrap(); + + let mut rseed_bytes = [0u8; 32]; + rseed_bytes[0] = 1; + let rseed = Rseed::BeforeZip212(jubjub::Fr::from_bytes(&rseed_bytes).unwrap()); + let note = Note::from_parts(address.clone(), NoteValue::from_raw(20_000_000), rseed); + let spendable = crate::notes::SpendableNote::new( + note, + address.clone(), + 0, + Nullifier([0u8; 32]), + 0, + 0, + 0, + ); + + // 32 canonical sibling nodes (value 1, little-endian). + let mut sibling = [0u8; 32]; + sibling[0] = 1; + let witness_hex = hex::encode(sibling).repeat(32); + let path = crate::notes::parse_merkle_path(&witness_hex, 0).unwrap(); + + let builder = TransactionBuilder::new( + manager.extended_spending_key().clone(), + manager.diversifiable_full_viewing_key().clone(), + false, + ); + let outputs = vec![(address, 12_000_000u64, None)]; + + (builder, vec![spendable], vec![path], outputs) + } + + #[test] + fn test_build_route_transaction_rejects_witness_anchor_mismatch() { + let (builder, notes, paths, outputs) = route_transaction_fixture(); + + // The empty-tree anchor cannot match a witness with nonzero siblings. + let err = builder + .build_route_transaction( + notes, + paths, + Anchor::empty_tree(), + outputs, + Vec::new(), + 365_000, + ) + .unwrap_err(); + assert!( + err.to_string().contains("witness_anchor_mismatch"), + "expected witness_anchor_mismatch, got: {}", + err + ); + } + + #[test] + fn test_build_route_transaction_witness_check_runs_before_proving() { + let (builder, notes, paths, outputs) = route_transaction_fixture(); + + // With the matching anchor the witness gate passes and the build + // stops at the prover check instead, proving the root comparison + // happens before any proving work. + let leaf = Node::from_cmu(¬es[0].note.cmu()); + let anchor = Anchor::from(paths[0].root(leaf)); + let err = builder + .build_route_transaction(notes, paths, anchor, outputs, Vec::new(), 365_000) + .unwrap_err(); + assert!( + matches!(err, SaplingError::ProverNotInitialized), + "expected ProverNotInitialized, got: {}", + err + ); + } + + #[test] + fn test_pivx_sighash_personalization_is_padded() { + let personalization = pivx_sighash_personalization(); + assert_eq!(&personalization[..11], b"PIVXSigHash"); + assert_eq!(personalization[11], 0); + assert_eq!(&personalization[12..16], &0u32.to_le_bytes()); + } +} diff --git a/cw_pivx/rust/src/types.rs b/cw_pivx/rust/src/types.rs new file mode 100644 index 0000000000..8305f7a51d --- /dev/null +++ b/cw_pivx/rust/src/types.rs @@ -0,0 +1,312 @@ +//! Common types used across the library. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +/// Network type for PIVX +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] +pub enum Network { + Mainnet = 0, + Testnet = 1, +} + +impl Network { + pub fn coin_type(&self) -> u32 { + match self { + Network::Mainnet => 119, + Network::Testnet => 1, + } + } + + pub fn hrp_sapling_payment_address(&self) -> &'static str { + match self { + Network::Mainnet => "ps", + Network::Testnet => "ptestsapling", + } + } + + pub fn hrp_sapling_extended_spending_key(&self) -> &'static str { + match self { + Network::Mainnet => "p-secret-extended-key-main", + Network::Testnet => "p-secret-extended-key-test", + } + } + + pub fn hrp_sapling_extended_full_viewing_key(&self) -> &'static str { + match self { + Network::Mainnet => "pviews", + Network::Testnet => "pviewtestsapling", + } + } + + pub fn hrp_sapling_incoming_viewing_key(&self) -> &'static str { + match self { + Network::Mainnet => "pivks", + Network::Testnet => "pivktestsapling", + } + } + + pub fn sapling_activation_height(&self) -> u32 { + match self { + Network::Mainnet => 2_700_500, + Network::Testnet => 201, + } + } +} + +impl From for Network { + fn from(is_testnet: bool) -> Self { + if is_testnet { + Network::Testnet + } else { + Network::Mainnet + } + } +} + +/// Represents a spendable note with all required data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpendableNoteData { + /// Diversifier (11 bytes, hex encoded) + pub diversifier: String, + /// Diversified transmission key pk_d (32 bytes, hex encoded) + pub pk_d: String, + /// Note value in zatoshis + pub value: u64, + /// Commitment randomness (32 bytes, hex encoded) + pub rcm: String, + /// Note randomness seed (32 bytes, hex encoded) + pub rseed: String, + /// Incremental witness path (hex encoded concatenated 32-byte hashes) + pub witness: String, + /// Position in the commitment tree (from witness response) + #[serde(default)] + pub witness_position: u64, + /// Nullifier (32 bytes, hex encoded) + pub nullifier: String, + /// Note commitment (cmu) for validation + #[serde(default)] + pub cmu: Option, + /// Optional memo + pub memo: Option, +} + +impl SpendableNoteData { + fn zeroize_sensitive_fields(&mut self) { + self.diversifier.zeroize(); + self.pk_d.zeroize(); + self.rcm.zeroize(); + self.rseed.zeroize(); + self.witness.zeroize(); + self.nullifier.zeroize(); + if let Some(cmu) = self.cmu.as_mut() { + cmu.zeroize(); + } + if let Some(memo) = self.memo.as_mut() { + memo.zeroize(); + } + } +} + +impl Drop for SpendableNoteData { + fn drop(&mut self) { + self.zeroize_sensitive_fields(); + } +} + +/// Result of creating a transaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionResult { + /// Transaction ID + pub txid: String, + /// Signed transaction as hex + pub tx_hex: String, + /// Nullifiers of spent notes + pub nullifiers: Vec, + /// Transaction fee in zatoshis + pub fee: u64, +} + +impl TransactionResult { + fn zeroize_sensitive_fields(&mut self) { + self.txid.zeroize(); + self.tx_hex.zeroize(); + for nullifier in &mut self.nullifiers { + nullifier.zeroize(); + } + self.nullifiers.clear(); + } +} + +impl Drop for TransactionResult { + fn drop(&mut self) { + self.zeroize_sensitive_fields(); + } +} + +/// Transparent UTXO for shielding transactions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransparentUtxoData { + /// Transaction ID + pub txid: String, + /// Output index + pub vout: u32, + /// Value in zatoshis + pub value: u64, + /// Script pubkey (hex encoded) + pub script_pubkey: String, + /// Private key (WIF or hex) + pub private_key: String, +} + +impl TransparentUtxoData { + fn zeroize_sensitive_fields(&mut self) { + self.txid.zeroize(); + self.script_pubkey.zeroize(); + self.private_key.zeroize(); + } +} + +impl Drop for TransparentUtxoData { + fn drop(&mut self) { + self.zeroize_sensitive_fields(); + } +} + +/// Options for creating a transaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionOptions { + /// Destination address (shielded or transparent) + pub to_address: String, + /// Amount in zatoshis + pub amount: u64, + /// Optional memo (max 512 bytes) + pub memo: Option, + /// Change address (defaults to own shielded address) + pub change_address: Option, + /// Current block height + pub block_height: u32, +} + +impl TransactionOptions { + fn zeroize_sensitive_fields(&mut self) { + self.to_address.zeroize(); + if let Some(memo) = self.memo.as_mut() { + memo.zeroize(); + } + if let Some(change_address) = self.change_address.as_mut() { + change_address.zeroize(); + } + } +} + +impl Drop for TransactionOptions { + fn drop(&mut self) { + self.zeroize_sensitive_fields(); + } +} + +/// Sync status information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncStatus { + /// Last synced block height + pub last_synced_block: u32, + /// Current chain tip + pub current_block: u32, + /// Sync progress (0.0 to 1.0) + pub progress: f32, + /// Whether sync is in progress + pub is_syncing: bool, + /// Error message if any + pub error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spendable_note_data_zeroizes_sensitive_strings() { + let mut note = SpendableNoteData { + diversifier: "0102030405060708090a0b".to_string(), + pk_d: "11".repeat(32), + value: 42, + rcm: "22".repeat(32), + rseed: "33".repeat(32), + witness: "44".repeat(1024), + witness_position: 7, + nullifier: "55".repeat(32), + cmu: Some("66".repeat(32)), + memo: Some("sensitive memo".to_string()), + }; + + note.zeroize_sensitive_fields(); + + assert!(note.diversifier.is_empty()); + assert!(note.pk_d.is_empty()); + assert_eq!(note.value, 42); + assert!(note.rcm.is_empty()); + assert!(note.rseed.is_empty()); + assert!(note.witness.is_empty()); + assert_eq!(note.witness_position, 7); + assert!(note.nullifier.is_empty()); + assert_eq!(note.cmu.as_deref(), Some("")); + assert_eq!(note.memo.as_deref(), Some("")); + } + + #[test] + fn transaction_result_zeroizes_sensitive_strings() { + let mut result = TransactionResult { + txid: "aa".repeat(32), + tx_hex: "bb".repeat(1200), + nullifiers: vec!["cc".repeat(32), "dd".repeat(32)], + fee: 10000, + }; + + result.zeroize_sensitive_fields(); + + assert!(result.txid.is_empty()); + assert!(result.tx_hex.is_empty()); + assert!(result.nullifiers.is_empty()); + assert_eq!(result.fee, 10000); + } + + #[test] + fn transparent_utxo_data_zeroizes_sensitive_strings() { + let mut utxo = TransparentUtxoData { + txid: "aa".repeat(32), + vout: 1, + value: 12345, + script_pubkey: "76a914".to_string(), + private_key: "secret-wif".to_string(), + }; + + utxo.zeroize_sensitive_fields(); + + assert!(utxo.txid.is_empty()); + assert_eq!(utxo.vout, 1); + assert_eq!(utxo.value, 12345); + assert!(utxo.script_pubkey.is_empty()); + assert!(utxo.private_key.is_empty()); + } + + #[test] + fn transaction_options_zeroizes_sensitive_strings() { + let mut options = TransactionOptions { + to_address: "ps1recipient".to_string(), + amount: 42, + memo: Some("memo".to_string()), + change_address: Some("ps1change".to_string()), + block_height: 123, + }; + + options.zeroize_sensitive_fields(); + + assert!(options.to_address.is_empty()); + assert_eq!(options.amount, 42); + assert_eq!(options.memo.as_deref(), Some("")); + assert_eq!(options.change_address.as_deref(), Some("")); + assert_eq!(options.block_height, 123); + } +} diff --git a/cw_pivx/rust/src/utils.rs b/cw_pivx/rust/src/utils.rs new file mode 100644 index 0000000000..f4a83a5837 --- /dev/null +++ b/cw_pivx/rust/src/utils.rs @@ -0,0 +1,29 @@ +//! Utility functions. + +use std::ffi::{c_char, CString}; +use std::ptr; + +pub fn string_to_c(s: String) -> *mut c_char { + match CString::new(s) { + Ok(cs) => cs.into_raw(), + Err(_) => ptr::null_mut(), + } +} + +pub fn bytes_to_hex(bytes: &[u8]) -> String { + hex::encode(bytes) +} + +pub fn hex_to_bytes(s: &str) -> Result, hex::FromHexError> { + hex::decode(s) +} + +pub fn u64_to_le_bytes(v: u64) -> [u8; 8] { + v.to_le_bytes() +} + +pub fn le_bytes_to_u64(bytes: &[u8]) -> u64 { + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes[..8]); + u64::from_le_bytes(arr) +} diff --git a/cw_pivx/rust/tests/testnet_integration.rs b/cw_pivx/rust/tests/testnet_integration.rs new file mode 100644 index 0000000000..f15c203dc6 --- /dev/null +++ b/cw_pivx/rust/tests/testnet_integration.rs @@ -0,0 +1,42 @@ +//! PIVX Sapling sighash personalization test. + +/// PIVX and Zcash use different BLAKE2b personalizations, so the same input must +/// hash differently. Guards against accidentally reusing Zcash constants. +#[test] +fn test_blake2b_sighash() { + use blake2b_simd::Params; + + // Create BLAKE2b hasher with PIVX personalization + let mut personalization = [0u8; 16]; + personalization[..11].copy_from_slice(b"PIVXSigHash"); + personalization[12..16].copy_from_slice(&0u32.to_le_bytes()); + + let mut hasher = Params::new() + .hash_length(32) + .personal(&personalization) + .to_state(); + + hasher.update(b"test data"); + let hash = hasher.finalize(); + + assert_eq!(hash.as_bytes().len(), 32, "Hash must be 32 bytes"); + + // Verify different personalization produces different hash + let mut zcash_personalization = [0u8; 16]; + zcash_personalization[..12].copy_from_slice(b"ZcashSigHash"); + zcash_personalization[12..16].copy_from_slice(&0x03C48270u32.to_le_bytes()); + + let mut zcash_hasher = Params::new() + .hash_length(32) + .personal(&zcash_personalization) + .to_state(); + + zcash_hasher.update(b"test data"); + let zcash_hash = zcash_hasher.finalize(); + + assert_ne!( + hash.as_bytes(), + zcash_hash.as_bytes(), + "PIVX and Zcash sighashes MUST be different for same data" + ); +} diff --git a/cw_pivx/scripts/build_all.sh b/cw_pivx/scripts/build_all.sh new file mode 100755 index 0000000000..7afb33312d --- /dev/null +++ b/cw_pivx/scripts/build_all.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Master build script for PIVX Sapling native library +# Builds for all platforms + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}=== PIVX Sapling Native Library Builder ===${NC}" +echo "" + +# Parse arguments +BUILD_IOS=false +BUILD_ANDROID=false +BUILD_MACOS=false +BUILD_LINUX=false +BUILD_ALL=true + +while [[ $# -gt 0 ]]; do + case $1 in + --ios) + BUILD_IOS=true + BUILD_ALL=false + shift + ;; + --android) + BUILD_ANDROID=true + BUILD_ALL=false + shift + ;; + --macos) + BUILD_MACOS=true + BUILD_ALL=false + shift + ;; + --linux) + BUILD_LINUX=true + BUILD_ALL=false + shift + ;; + --help) + echo "Usage: $0 [--ios] [--android] [--macos] [--linux]" + echo "" + echo "Options:" + echo " --ios Build for iOS (device and simulator)" + echo " --android Build for Android (all architectures)" + echo " --macos Build for macOS (universal binary)" + echo " --linux Build for Linux" + echo "" + echo "If no options are specified, builds for all platforms." + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + exit 1 + ;; + esac +done + +if [ "$BUILD_ALL" = true ]; then + BUILD_IOS=true + BUILD_ANDROID=true + BUILD_MACOS=true + BUILD_LINUX=true +fi + +# Detect platform +if [[ "$OSTYPE" == "darwin"* ]]; then + HOST_PLATFORM="macos" +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + HOST_PLATFORM="linux" +else + HOST_PLATFORM="unknown" +fi + +echo -e "${YELLOW}Host platform: $HOST_PLATFORM${NC}" +echo "" + +# Build iOS (macOS only) +if [ "$BUILD_IOS" = true ]; then + if [ "$HOST_PLATFORM" = "macos" ]; then + echo -e "${YELLOW}Building for iOS...${NC}" + bash "$SCRIPT_DIR/build_ios.sh" + echo "" + else + echo -e "${YELLOW}Skipping iOS build (requires macOS)${NC}" + fi +fi + +# Build Android +if [ "$BUILD_ANDROID" = true ]; then + echo -e "${YELLOW}Building for Android...${NC}" + bash "$SCRIPT_DIR/build_android.sh" + echo "" +fi + +# Build macOS (macOS only) +if [ "$BUILD_MACOS" = true ]; then + if [ "$HOST_PLATFORM" = "macos" ]; then + echo -e "${YELLOW}Building for macOS...${NC}" + bash "$SCRIPT_DIR/build_macos.sh" + echo "" + else + echo -e "${YELLOW}Skipping macOS build (requires macOS)${NC}" + fi +fi + +# Build Linux +if [ "$BUILD_LINUX" = true ]; then + if [ "$HOST_PLATFORM" = "linux" ]; then + echo -e "${YELLOW}Building for Linux...${NC}" + bash "$SCRIPT_DIR/build_linux.sh" + echo "" + else + echo -e "${YELLOW}Skipping Linux build (requires Linux)${NC}" + fi +fi + +echo -e "${GREEN}=== Build Complete ===${NC}" diff --git a/cw_pivx/scripts/build_android.sh b/cw_pivx/scripts/build_android.sh new file mode 100755 index 0000000000..7feabbb27c --- /dev/null +++ b/cw_pivx/scripts/build_android.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# Build script for PIVX Sapling native library - Android +# This builds for all Android architectures (arm64-v8a, armeabi-v7a, x86_64, x86) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RUST_DIR="$SCRIPT_DIR/../rust" +OUTPUT_DIR="$SCRIPT_DIR/../android/src/main/jniLibs" +LIB_NAME="cw_pivx_sapling" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Building PIVX Sapling library for Android...${NC}" + +# Check for required tools +if ! command -v cargo &> /dev/null; then + echo -e "${RED}Error: cargo is not installed. Please install Rust.${NC}" + exit 1 +fi + +# Check for Android NDK +if [ -z "$ANDROID_NDK_HOME" ]; then + # Try common locations + if [ -d "$HOME/Library/Android/sdk/ndk" ]; then + ANDROID_NDK_HOME=$(ls -d "$HOME/Library/Android/sdk/ndk"/*/ | tail -1) + elif [ -d "$ANDROID_HOME/ndk" ]; then + ANDROID_NDK_HOME=$(ls -d "$ANDROID_HOME/ndk"/*/ | tail -1) + elif [ -d "/usr/local/lib/android/sdk/ndk" ]; then + ANDROID_NDK_HOME=$(ls -d "/usr/local/lib/android/sdk/ndk"/*/ | tail -1) + fi +fi + +if [ -z "$ANDROID_NDK_HOME" ]; then + echo -e "${RED}Error: ANDROID_NDK_HOME is not set. Please set it to your NDK path.${NC}" + exit 1 +fi + +echo -e "${YELLOW}Using NDK: $ANDROID_NDK_HOME${NC}" + +# Install Android targets +rustup target add aarch64-linux-android +rustup target add armv7-linux-androideabi +rustup target add x86_64-linux-android +rustup target add i686-linux-android + +# Install cargo-ndk if not present +if ! command -v cargo-ndk &> /dev/null; then + echo -e "${YELLOW}Installing cargo-ndk...${NC}" + cargo install cargo-ndk +fi + +cd "$RUST_DIR" + +# Create output directories +mkdir -p "$OUTPUT_DIR/arm64-v8a" +mkdir -p "$OUTPUT_DIR/armeabi-v7a" +mkdir -p "$OUTPUT_DIR/x86_64" +mkdir -p "$OUTPUT_DIR/x86" + +# Build for each architecture +echo -e "${YELLOW}Building for arm64-v8a...${NC}" +cargo ndk -t arm64-v8a -o "$OUTPUT_DIR" build --release + +echo -e "${YELLOW}Building for armeabi-v7a...${NC}" +cargo ndk -t armeabi-v7a -o "$OUTPUT_DIR" build --release + +echo -e "${YELLOW}Building for x86_64...${NC}" +cargo ndk -t x86_64 -o "$OUTPUT_DIR" build --release + +echo -e "${YELLOW}Building for x86...${NC}" +cargo ndk -t x86 -o "$OUTPUT_DIR" build --release + +# Rename libraries to match expected names +missing_arch=false +for arch in arm64-v8a armeabi-v7a x86_64 x86; do + if [ -s "$OUTPUT_DIR/$arch/lib${LIB_NAME}.so" ]; then + echo -e "${GREEN}✓ Built lib${LIB_NAME}.so for $arch${NC}" + else + echo -e "${RED}✗ Failed to build for $arch${NC}" + missing_arch=true + fi +done + +if [ "$missing_arch" = true ]; then + echo -e "${RED}Error: one or more Android PIVX Sapling libraries are missing.${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ Android build complete!${NC}" +echo -e "${GREEN} Libraries: $OUTPUT_DIR${NC}" diff --git a/cw_pivx/scripts/build_ios.sh b/cw_pivx/scripts/build_ios.sh new file mode 100755 index 0000000000..c7d249dc6c --- /dev/null +++ b/cw_pivx/scripts/build_ios.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Build script for PIVX Sapling native library - iOS +# This builds a universal framework for iOS devices and simulators + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RUST_DIR="$SCRIPT_DIR/../rust" +OUTPUT_DIR="$SCRIPT_DIR/../ios/Frameworks" +LIB_NAME="cw_pivx_sapling" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Building PIVX Sapling library for iOS...${NC}" + +# Check for required tools +if ! command -v cargo &> /dev/null; then + echo -e "${RED}Error: cargo is not installed. Please install Rust.${NC}" + exit 1 +fi + +if ! command -v lipo &> /dev/null; then + echo -e "${RED}Error: lipo is not installed. Please install Xcode Command Line Tools.${NC}" + exit 1 +fi + +# Install iOS targets if not present +rustup target add aarch64-apple-ios +rustup target add aarch64-apple-ios-sim +rustup target add x86_64-apple-ios + +cd "$RUST_DIR" + +echo -e "${YELLOW}Building for aarch64-apple-ios (device)...${NC}" +cargo build --release --target aarch64-apple-ios + +echo -e "${YELLOW}Building for aarch64-apple-ios-sim (Apple Silicon simulator)...${NC}" +cargo build --release --target aarch64-apple-ios-sim + +echo -e "${YELLOW}Building for x86_64-apple-ios (Intel simulator)...${NC}" +cargo build --release --target x86_64-apple-ios + +# Create output directory +mkdir -p "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR/device" +mkdir -p "$OUTPUT_DIR/simulator" + +# Create universal binary for simulators +echo -e "${YELLOW}Creating universal simulator binary...${NC}" +lipo -create \ + target/aarch64-apple-ios-sim/release/lib${LIB_NAME}.a \ + target/x86_64-apple-ios/release/lib${LIB_NAME}.a \ + -output "$OUTPUT_DIR/simulator/lib${LIB_NAME}.a" + +# Copy device binary (keep the same name) +cp target/aarch64-apple-ios/release/lib${LIB_NAME}.a "$OUTPUT_DIR/device/lib${LIB_NAME}.a" + +# Create xcframework +echo -e "${YELLOW}Creating XCFramework...${NC}" +rm -rf "$OUTPUT_DIR/${LIB_NAME}.xcframework" + +xcodebuild -create-xcframework \ + -library "$OUTPUT_DIR/device/lib${LIB_NAME}.a" \ + -library "$OUTPUT_DIR/simulator/lib${LIB_NAME}.a" \ + -output "$OUTPUT_DIR/${LIB_NAME}.xcframework" + +# Generate header +echo -e "${YELLOW}Generating C header...${NC}" +cbindgen --lang c --output "$OUTPUT_DIR/cw_pivx_sapling.h" "$RUST_DIR" + +# Clean up intermediate files +rm -rf "$OUTPUT_DIR/device" +rm -rf "$OUTPUT_DIR/simulator" + +echo -e "${GREEN}✓ iOS build complete!${NC}" +echo -e "${GREEN} XCFramework: $OUTPUT_DIR/${LIB_NAME}.xcframework${NC}" +echo -e "${GREEN} Header: $OUTPUT_DIR/cw_pivx_sapling.h${NC}" diff --git a/cw_pivx/scripts/build_linux.sh b/cw_pivx/scripts/build_linux.sh new file mode 100755 index 0000000000..3f24c0f310 --- /dev/null +++ b/cw_pivx/scripts/build_linux.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Build script for PIVX Sapling native library - Linux +# This builds for the current Linux architecture + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RUST_DIR="$SCRIPT_DIR/../rust" +OUTPUT_DIR="$SCRIPT_DIR/../linux/lib" +LIB_NAME="cw_pivx_sapling" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Building PIVX Sapling library for Linux...${NC}" + +# Check for required tools +if ! command -v cargo &> /dev/null; then + echo -e "${RED}Error: cargo is not installed. Please install Rust.${NC}" + exit 1 +fi + +cd "$RUST_DIR" + +echo -e "${YELLOW}Building for current architecture...${NC}" +cargo build --release + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Determine target directory +TARGET_DIR="target/release" + +# Copy library +if [ -f "$TARGET_DIR/lib${LIB_NAME}.so" ]; then + cp "$TARGET_DIR/lib${LIB_NAME}.so" "$OUTPUT_DIR/" + echo -e "${GREEN}✓ Built lib${LIB_NAME}.so${NC}" +fi + +if [ -f "$TARGET_DIR/lib${LIB_NAME}.a" ]; then + cp "$TARGET_DIR/lib${LIB_NAME}.a" "$OUTPUT_DIR/" + echo -e "${GREEN}✓ Built lib${LIB_NAME}.a${NC}" +fi + +# Generate header +echo -e "${YELLOW}Generating C header...${NC}" +if command -v cbindgen &> /dev/null; then + cbindgen --lang c --output "$OUTPUT_DIR/cw_pivx_sapling.h" "$RUST_DIR" +else + echo -e "${YELLOW}cbindgen not found, skipping header generation${NC}" +fi + +echo -e "${GREEN}✓ Linux build complete!${NC}" +echo -e "${GREEN} Output: $OUTPUT_DIR${NC}" diff --git a/cw_pivx/scripts/build_macos.sh b/cw_pivx/scripts/build_macos.sh new file mode 100755 index 0000000000..dd1ed8b616 --- /dev/null +++ b/cw_pivx/scripts/build_macos.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Build script for PIVX Sapling native library - macOS +# This builds a universal dylib for macOS (arm64 + x86_64) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RUST_DIR="$SCRIPT_DIR/../rust" +OUTPUT_DIR="$SCRIPT_DIR/../macos/Frameworks" +LIB_NAME="cw_pivx_sapling" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Building PIVX Sapling library for macOS...${NC}" + +# Check for required tools +if ! command -v cargo &> /dev/null; then + echo -e "${RED}Error: cargo is not installed. Please install Rust.${NC}" + exit 1 +fi + +# Install macOS targets +rustup target add aarch64-apple-darwin +rustup target add x86_64-apple-darwin + +cd "$RUST_DIR" + +echo -e "${YELLOW}Building for aarch64-apple-darwin (Apple Silicon)...${NC}" +cargo build --release --target aarch64-apple-darwin + +echo -e "${YELLOW}Building for x86_64-apple-darwin (Intel)...${NC}" +cargo build --release --target x86_64-apple-darwin + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Create universal binary +echo -e "${YELLOW}Creating universal binary...${NC}" +lipo -create \ + target/aarch64-apple-darwin/release/lib${LIB_NAME}.dylib \ + target/x86_64-apple-darwin/release/lib${LIB_NAME}.dylib \ + -output "$OUTPUT_DIR/lib${LIB_NAME}.dylib" + +# Also create static library +lipo -create \ + target/aarch64-apple-darwin/release/lib${LIB_NAME}.a \ + target/x86_64-apple-darwin/release/lib${LIB_NAME}.a \ + -output "$OUTPUT_DIR/lib${LIB_NAME}.a" + +# Generate header +echo -e "${YELLOW}Generating C header...${NC}" +cbindgen --lang c --output "$OUTPUT_DIR/cw_pivx_sapling.h" "$RUST_DIR" + +echo -e "${GREEN}✓ macOS build complete!${NC}" +echo -e "${GREEN} Dynamic library: $OUTPUT_DIR/lib${LIB_NAME}.dylib${NC}" +echo -e "${GREEN} Static library: $OUTPUT_DIR/lib${LIB_NAME}.a${NC}" +echo -e "${GREEN} Header: $OUTPUT_DIR/cw_pivx_sapling.h${NC}" diff --git a/cw_pivx/test/atomic_tree_position_test.dart b/cw_pivx/test/atomic_tree_position_test.dart new file mode 100644 index 0000000000..42eb14095c --- /dev/null +++ b/cw_pivx/test/atomic_tree_position_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cw_pivx/src/sapling/utils/atomic_tree_position.dart'; + +void main() { + group('AtomicTreePosition', () { + test('initializes to zero', () { + final position = AtomicTreePosition(); + expect(position.current, equals(0)); + }); + + test('initialize sets position', () { + final position = AtomicTreePosition(); + position.initialize(1000); + expect(position.current, equals(1000)); + }); + + test('reset returns to zero', () { + final position = AtomicTreePosition(); + position.initialize(1000); + position.reset(); + expect(position.current, equals(0)); + }); + + test('reservePositions returns start position and increments', () async { + final position = AtomicTreePosition(); + + final start1 = await position.reservePositions(10); + expect(start1, equals(0)); + expect(position.current, equals(10)); + + final start2 = await position.reservePositions(5); + expect(start2, equals(10)); + expect(position.current, equals(15)); + }); + + test('concurrent reservations do not overlap', () async { + final position = AtomicTreePosition(); + + // Reserve positions concurrently + final futures = List.generate(100, (i) async { + return await position.reservePositions(10); + }); + + final results = await Future.wait(futures); + + // Verify we got 1000 unique positions + final allPositions = {}; + for (final start in results) { + for (int i = 0; i < 10; i++) { + allPositions.add(start + i); + } + } + + // All positions should be unique + expect(allPositions.length, equals(1000)); + expect(position.current, equals(1000)); + + // Positions should be 0-999 + expect(allPositions.toList()..sort(), equals(List.generate(1000, (i) => i))); + }); + + test('high concurrency stress test', () async { + final position = AtomicTreePosition(); + + // Simulate very high concurrency (500 concurrent reservations) + final futures = List.generate(500, (i) async { + // Variable sized reservations (1-20) + final count = (i % 20) + 1; + return await position.reservePositions(count); + }); + + final results = await Future.wait(futures); + + // Calculate expected total + final expectedTotal = List.generate(500, (i) => (i % 20) + 1).reduce((a, b) => a + b); + expect(position.current, equals(expectedTotal)); + + // Verify no overlaps by checking all positions are unique + final allPositions = {}; + for (int i = 0; i < results.length; i++) { + final start = results[i]; + final count = (i % 20) + 1; + for (int j = 0; j < count; j++) { + final pos = start + j; + expect(allPositions.contains(pos), false, + reason: 'Position $pos was already assigned! Start: $start, Count: $count'); + allPositions.add(pos); + } + } + + expect(allPositions.length, equals(expectedTotal)); + }); + + test('sequential reservations maintain order', () async { + final position = AtomicTreePosition(); + final reserved = []; + + for (int i = 0; i < 50; i++) { + final start = await position.reservePositions(5); + reserved.add(start); + } + + // Verify positions increment by 5 each time + for (int i = 1; i < reserved.length; i++) { + expect(reserved[i], equals(reserved[i-1] + 5)); + } + }); + + test('single position reservation', () async { + final position = AtomicTreePosition(); + + final pos1 = await position.reservePositions(1); + final pos2 = await position.reservePositions(1); + final pos3 = await position.reservePositions(1); + + expect(pos1, equals(0)); + expect(pos2, equals(1)); + expect(pos3, equals(2)); + expect(position.current, equals(3)); + }); + + test('large block reservation', () async { + final position = AtomicTreePosition(); + + // Reserve a large block (simulating a block with many outputs) + final start = await position.reservePositions(1000); + expect(start, equals(0)); + expect(position.current, equals(1000)); + + // Next reservation should start after + final start2 = await position.reservePositions(1); + expect(start2, equals(1000)); + }); + + test('interleaved concurrent and sequential operations', () async { + final position = AtomicTreePosition(); + + // Start with sequential + await position.reservePositions(10); + + // Then concurrent + final concurrentFutures = List.generate(10, (_) => position.reservePositions(5)); + final concurrentResults = await Future.wait(concurrentFutures); + + // Then sequential again + final seqResult = await position.reservePositions(10); + + expect(position.current, equals(10 + 50 + 10)); // 70 total + expect(seqResult, equals(60)); // Should start at 60 + }); + }); +} diff --git a/cw_pivx/test/cw_pivx_test.dart b/cw_pivx/test/cw_pivx_test.dart new file mode 100644 index 0000000000..50dc60efc5 --- /dev/null +++ b/cw_pivx/test/cw_pivx_test.dart @@ -0,0 +1,631 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/bitcoin_address_record.dart'; +import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; +import 'package:cw_bitcoin/electrum.dart' as electrum; +import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/cake_hive.dart'; +import 'package:cw_core/db/sqlite.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/payment_uris.dart'; +import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/unspent_coins_info.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:cw_pivx/src/pending_pivx_shielded_transaction.dart'; +import 'package:cw_pivx/src/pivx_network.dart'; +import 'package:cw_pivx/src/pivx_wallet.dart'; +import 'package:cw_pivx/src/pivx_wallet_creation_credentials.dart'; +import 'package:cw_pivx/src/sapling/sapling_factories.dart' as sapling; +import 'package:cw_pivx/src/sapling/sapling_note_storage.dart'; +import 'package:convert/convert.dart' as convert; +import 'package:hive/hive.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => Directory.systemTemp.path, + ); + + late Directory dbDir; + late Directory hiveDir; + late Box unspentCoinsInfo; + var dbInitialized = false; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + dbDir = await Directory.systemTemp.createTemp('pivx_restore_test_'); + hiveDir = await Directory.systemTemp.createTemp('pivx_hive_test_'); + databaseFactory = databaseFactoryFfi; + await initDb(pathOverride: '${dbDir.path}/cake.db'); + CakeHive.init(hiveDir.path); + if (!CakeHive.isAdapterRegistered(UnspentCoinsInfo.typeId)) { + CakeHive.registerAdapter(UnspentCoinsInfoAdapter()); + } + unspentCoinsInfo = await CakeHive.openBox( + '${UnspentCoinsInfo.boxName}_pivx_test', + ); + dbInitialized = true; + }); + + tearDownAll(() async { + await unspentCoinsInfo.close(); + if (dbInitialized) { + await db?.close(); + } + if (await hiveDir.exists()) { + await hiveDir.delete(recursive: true); + } + if (await dbDir.exists()) { + await dbDir.delete(recursive: true); + } + }); + + group('PivxNetwork', () { + test('mainnet has correct prefixes', () { + expect(PivxNetwork.mainnet.p2pkhNetVer, [30]); + expect(PivxNetwork.mainnet.p2shNetVer, [13]); + expect(PivxNetwork.mainnet.wifNetVer, [212]); + expect(PivxNetwork.coinType, 119); + }); + + test('testnet has correct prefixes', () { + expect(PivxNetwork.testnet.p2pkhNetVer, [139]); + expect(PivxNetwork.testnet.p2shNetVer, [19]); + expect(PivxNetwork.testnet.wifNetVer, [239]); + expect(PivxNetwork.coinType, 119); + }); + + test('mainnet has correct network parameters', () { + expect(PivxNetwork.defaultPort, 51472); + expect(PivxNetwork.rpcPort, 51473); + expect(PivxNetwork.coinbaseMaturity, 100); + expect(PivxNetwork.targetBlockTime, 60); + expect(PivxNetwork.minRelayTxFee, 10000); + }); + + test('isValidAddress validates correctly', () { + // Valid P2PKH address (starts with D) + expect(PivxNetwork.isValidAddress('D'), false); // Too short + + // Valid staking address (starts with S) + // Note: Full validation requires proper base58 check + + // Invalid addresses + expect(PivxNetwork.isValidAddress(''), false); + expect(PivxNetwork.isValidAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'), + false); + }); + }); + + group('PivxRestoreWalletFromSeedCredentials', () { + test('preserves restore height for transparent and shielded rescans', () { + final credentials = PivxRestoreWalletFromSeedCredentials( + name: 'restore-height', + password: 'password', + mnemonic: + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + height: 2700500, + ); + + expect(credentials.height, 2700500); + }); + }); + + group('PIVX shielded sync errors', () { + test('explains missing v1 global output position support', () { + final message = PivxWalletBase.sanitizeShieldSyncError(Exception( + 'PIVX Sapling sync cannot start after activation without a persisted tree cursor or server global output positions')); + + expect( + message, + 'PIVX Sapling sync requires a Sapling v1 ElectrumX node with global output positions. Switch nodes and retry.', + ); + }); + + test('keeps unknown shielded sync errors generic', () { + final message = PivxWalletBase.sanitizeShieldSyncError( + Exception('some unexpected sanitized failure'), + ); + + expect( + message, + 'PIVX Sapling sync failed. Check node capability and retry.', + ); + }); + + test('explains incomplete advertised v1 support', () { + final message = PivxWalletBase.sanitizeShieldSyncError(Exception( + 'PIVX Sapling node advertises v1 but is missing required release contract features')); + + expect( + message, + 'Current PIVX node advertises incomplete Sapling v1 support. Switch to a fully upgraded Sapling v1 node and retry.', + ); + }); + + test('explains retryable incomplete block ranges', () { + final message = PivxWalletBase.sanitizeShieldSyncError(Exception( + 'PIVX Sapling block range 5441053-5441053 failed after 3 attempts: PIVX Sapling get_block_range returned an incomplete range for 5441053-5441053')); + + expect( + message, + 'Current PIVX node did not return a complete Sapling block range yet. Wait for the node to finish indexing and retry.', + ); + }); + }); + + group('PIVX restore discovery', () { + test('extends transparent receive batches until a full unused gap is found', + () async { + final initialReceiveAddresses = + List.generate(22, (index) => _addressRecord(index: index)); + final walletAddresses = _TestElectrumWalletAddresses( + initialAddresses: initialReceiveAddresses); + final queriedIndexes = []; + + await walletAddresses.discoverAddresses( + initialReceiveAddresses, + false, + (address) async { + queriedIndexes.add(address.index); + return {41, 61}.contains(address.index) ? address.address : null; + }, + type: P2pkhAddressType.p2pkh, + isLegacyDerivation: false, + ); + + final receiveAddresses = walletAddresses.allAddresses + .where((address) => + !address.isHidden && address.type == P2pkhAddressType.p2pkh) + .toList(); + expect(receiveAddresses.length, 82); + expect(receiveAddresses.map((address) => address.index), + containsAll([41, 61, 81])); + expect(queriedIndexes.first, 22); + expect(queriedIndexes.last, 81); + }); + + test('extends transparent change batches until a full unused gap is found', + () async { + final initialChangeAddresses = List.generate( + 17, (index) => _addressRecord(index: index, isHidden: true)); + final walletAddresses = _TestElectrumWalletAddresses( + initialAddresses: initialChangeAddresses); + final queriedIndexes = []; + + await walletAddresses.discoverAddresses( + initialChangeAddresses, + true, + (address) async { + queriedIndexes.add(address.index); + return {36, 56}.contains(address.index) ? address.address : null; + }, + type: P2pkhAddressType.p2pkh, + isLegacyDerivation: false, + ); + + final changeAddresses = walletAddresses.allAddresses + .where((address) => + address.isHidden && address.type == P2pkhAddressType.p2pkh) + .toList(); + expect(changeAddresses.length, 77); + expect(changeAddresses.map((address) => address.index), + containsAll([36, 56, 76])); + expect(queriedIndexes.first, 17); + expect(queriedIndexes.last, 76); + }); + + test('advances shielded receive index past observed diversified recipients', + () async { + final nextIndex = await PivxWalletBase + .nextShieldedDiversifierIndexAfterObservedAddresses( + currentNextDiversifierIndex: 1, + observedAddressHexes: {'aa', 'cc'}, + deriveAddressHex: (index) async => { + 2: 'AA', + 9: 'bb', + 27: 'cc', + }[index], + scanLimit: 50, + ); + + expect(nextIndex, 28); + }); + + test('does not move shielded receive index backwards or past scan limit', + () async { + final nextIndex = await PivxWalletBase + .nextShieldedDiversifierIndexAfterObservedAddresses( + currentNextDiversifierIndex: 10, + observedAddressHexes: {'aa', 'late'}, + deriveAddressHex: (index) async => { + 2: 'aa', + 75: 'late', + }[index], + scanLimit: 50, + ); + + expect(nextIndex, 10); + }); + }); + + group('PIVX transparent balance response handling', () { + test('preserves previous balance and marks lost connection on null confirmed', + () async { + final wallet = _testWallet( + unspentCoinsInfo: unspentCoinsInfo, + electrumClient: _FakeElectrumClient([ + {'confirmed': null, 'unconfirmed': 123}, + ]), + ); + wallet.shieldedBalance = 4444; + wallet.pendingShieldedBalance = 55; + + final balance = await wallet.fetchBalances(); + + expect(balance.confirmed.amount.toInt(), 7000); + expect(balance.unconfirmed.amount.toInt(), 300); + expect(balance.frozen.amount.toInt(), 9); + expect(balance.secondConfirmed!.amount.toInt(), 4444); + expect(balance.secondUnconfirmed!.amount.toInt(), 55); + expect(wallet.syncStatus, isA()); + }); + + test( + 'preserves previous balance and marks lost connection on null unconfirmed', + () async { + final wallet = _testWallet( + unspentCoinsInfo: unspentCoinsInfo, + electrumClient: _FakeElectrumClient([ + {'confirmed': 123, 'unconfirmed': null}, + ]), + ); + wallet.shieldedBalance = 2222; + wallet.pendingShieldedBalance = 33; + + final balance = await wallet.fetchBalances(); + + expect(balance.confirmed.amount.toInt(), 7000); + expect(balance.unconfirmed.amount.toInt(), 300); + expect(balance.frozen.amount.toInt(), 9); + expect(balance.secondConfirmed!.amount.toInt(), 2222); + expect(balance.secondUnconfirmed!.amount.toInt(), 33); + expect(wallet.syncStatus, isA()); + }); + }); + + group('PIVX shielded sync status', () { + test('clears shielded block progress when shielded sync completes', () { + final syncingStatus = PivxWalletBase.syncStatusForShieldProgress( + sapling.SyncStatus( + lastSyncedBlock: 5440918, + chainTip: 5440973, + blocksRemaining: 56, + progress: 0.9, + ), + ); + final completeStatus = PivxWalletBase.syncStatusForShieldProgress( + sapling.SyncStatus( + lastSyncedBlock: 5440973, + chainTip: 5440973, + blocksRemaining: 0, + progress: 1.0, + ), + ); + final initialStatus = PivxWalletBase.syncStatusForShieldProgress( + sapling.SyncStatus( + lastSyncedBlock: 5440418, + chainTip: 5440418, + blocksRemaining: 0, + progress: 0.0, + ), + ); + + expect(syncingStatus, isA()); + expect((syncingStatus as SyncingSyncStatus).blocksLeft, 56); + expect(completeStatus, isA()); + expect(initialStatus, isNull); + }); + }); + + group('PIVX shielded header sync cadence', () { + test('uses the PIVX 60 second target block time as sync throttle', () { + final now = DateTime.utc(2026, 6, 5, 12); + + expect( + PivxWalletBase.shouldRunShieldedHeaderSync( + lastSyncAt: null, + now: now, + ), + isTrue, + ); + expect( + PivxWalletBase.shouldRunShieldedHeaderSync( + lastSyncAt: now.subtract( + const Duration(seconds: PivxNetwork.shieldedHeaderSyncMinInterval - 1), + ), + now: now, + ), + isFalse, + ); + expect( + PivxWalletBase.shouldRunShieldedHeaderSync( + lastSyncAt: now.subtract( + const Duration(seconds: PivxNetwork.shieldedHeaderSyncMinInterval), + ), + now: now, + ), + isTrue, + ); + }); + }); + + group('PIVX shielded receive address selection', () { + test('restores latest generated shielded address as current', () { + final current = PivxWalletBase.currentShieldedReceiveAddressFromStorage([ + StoredShieldedAddress( + diversifierIndex: 1, + address: 'ps1generated1', + label: 'first', + ), + StoredShieldedAddress( + diversifierIndex: 4, + address: 'ps1generated4', + label: 'latest', + ), + StoredShieldedAddress( + diversifierIndex: 2, + address: 'ps1generated2', + label: 'middle', + ), + ]); + + expect(current.address, equals('ps1generated4')); + expect(current.label, equals('latest')); + }); + + test('fails closed when no stored generated shielded addresses exist', () { + expect( + () => PivxWalletBase.currentShieldedReceiveAddressFromStorage([]), + throwsA(isA()), + ); + }); + }); + + group('PIVX shielded broadcast diagnostics', () { + test('summarizes PIVX Sapling transaction shape without raw shielded data', + () { + final summary = PivxShieldedTransactionDebugSummary.fromHex( + _fakeShieldedTransactionHex( + valueBalance: 1417000, + spendCount: 1, + outputCount: 2, + ), + ); + + expect(summary.version, 3); + expect(summary.type, 0); + expect(summary.transparentInputCount, 0); + expect(summary.transparentOutputCount, 0); + expect(summary.hasSaplingData, true); + expect(summary.valueBalance, 1417000); + expect(summary.shieldedSpendCount, 1); + expect(summary.shieldedOutputCount, 2); + expect(summary.hasBindingSignature, true); + expect(summary.parseError, isNull); + expect(summary.toLogString(), contains('shielded_spends=1')); + expect(summary.toLogString(), isNot(contains('03000000'))); + }); + + test('maps Core Sapling rejection strings to actionable messages', () { + final spendMessage = + PendingPivxShieldedTransaction.sanitizeBroadcastError( + 'sendrawtransaction RPC error: bad-txns-sapling-spend-description-invalid', + ); + final requirementMessage = + PendingPivxShieldedTransaction.sanitizeBroadcastError( + 'bad-txns-shielded-requirements-not-met', + ); + + expect(spendMessage, contains('spend proof/signature validation failed')); + expect( + spendMessage, + contains('bad-txns-sapling-spend-description-invalid'), + ); + expect( + requirementMessage, + contains('anchor or nullifier requirements were not met'), + ); + }); + }); +} + +PivxWallet _testWallet({ + required Box unspentCoinsInfo, + required electrum.ElectrumClient electrumClient, +}) { + const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + return PivxWallet( + mnemonic: mnemonic, + password: 'password', + walletInfo: WalletInfo.external( + id: 'pivx_balance_test', + name: 'pivx_balance_test', + type: WalletType.pivx, + isRecovery: false, + restoreHeight: 0, + date: DateTime.fromMillisecondsSinceEpoch(0), + dirPath: '', + path: '', + address: '', + ), + derivationInfo: DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/44'/119'/0'", + scriptType: 'p2pkh', + ), + unspentCoinsInfo: unspentCoinsInfo, + seedBytes: MnemonicBip39.toSeed(mnemonic), + encryptionFileUtils: _FakeEncryptionFileUtils(), + initialAddresses: [_addressRecord(index: 0)], + initialBalance: ElectrumBalance( + confirmed: Money.fromInt(7000, CryptoCurrency.pivx), + unconfirmed: Money.fromInt(300, CryptoCurrency.pivx), + frozen: Money.fromInt(9, CryptoCurrency.pivx), + secondConfirmed: Money.fromInt(9999, CryptoCurrency.pivx), + secondUnconfirmed: Money.fromInt(88, CryptoCurrency.pivx), + ), + electrumClient: electrumClient, + ); +} + +class _FakeElectrumClient extends electrum.ElectrumClient { + _FakeElectrumClient(this.responses); + + final List> responses; + int _nextResponse = 0; + + @override + Future> getBalance( + String scriptHash, { + bool throwOnError = false, + }) async { + return responses[_nextResponse++]; + } +} + +class _FakeEncryptionFileUtils extends EncryptionFileUtils { + @override + Future write({ + required String path, + required String password, + required String data, + }) async {} + + @override + Future read({ + required String path, + required String password, + }) async { + throw UnimplementedError(); + } +} + +class _TestElectrumWalletAddresses extends ElectrumWalletAddressesBase { + _TestElectrumWalletAddresses({ + required List initialAddresses, + }) : super( + WalletInfo.external( + id: 'pivx_restore_discovery_test', + name: 'pivx_restore_discovery_test', + type: WalletType.pivx, + isRecovery: true, + restoreHeight: 0, + date: DateTime.fromMillisecondsSinceEpoch(0), + dirPath: '', + path: '', + address: '', + ), + mainHdByType: {P2pkhAddressType.p2pkh: _testMainHd}, + sideHdByType: {P2pkhAddressType.p2pkh: _testSideHd}, + legacyMainHd: _testMainHd, + legacySideHd: _testSideHd, + network: PivxNetwork.mainnet, + isHardwareWallet: false, + initialAddresses: initialAddresses, + initialAddressPageType: P2pkhAddressType.p2pkh, + ); + + @override + String getAddress({ + required int index, + required Bip32Slip10Secp256k1 hd, + BitcoinAddressType? addressType, + }) { + return _fakeAddress( + index: index, + isHidden: identical(hd, _testSideHd), + ); + } + + @override + PaymentURI getPaymentUri(String amount) => + PivxURI(amount: amount, address: address); +} + +BitcoinAddressRecord _addressRecord({ + required int index, + bool isHidden = false, +}) { + return BitcoinAddressRecord( + _fakeAddress(index: index, isHidden: isHidden), + index: index, + isHidden: isHidden, + type: P2pkhAddressType.p2pkh, + network: null, + ); +} + +String _fakeAddress({ + required int index, + required bool isHidden, +}) { + return generateP2PKHAddress( + hd: isHidden ? _testSideHd : _testMainHd, + index: index, + network: PivxNetwork.mainnet, + ); +} + +String _fakeShieldedTransactionHex({ + required int valueBalance, + required int spendCount, + required int outputCount, +}) { + final bytes = [ + 0x03, 0x00, // nVersion = Sapling + 0x00, 0x00, // nType = Normal + 0x00, // transparent vin count + 0x00, // transparent vout count + 0x00, 0x00, 0x00, 0x00, // nLockTime + 0x01, // Optional present + ..._int64Le(valueBalance), + spendCount, + ...List.filled(spendCount * 384, 0), + outputCount, + ...List.filled(outputCount * 948, 0), + ...List.filled(64, 0), + ]; + + return convert.hex.encode(bytes); +} + +List _int64Le(int value) { + final bytes = Uint8List(8); + var remaining = value; + for (var i = 0; i < bytes.length; i++) { + bytes[i] = remaining & 0xff; + remaining >>= 8; + } + return bytes; +} + +final _testAccountHd = Bip32Slip10Secp256k1.fromSeed(Uint8List(64)); +final _testMainHd = _testAccountHd.childKey(Bip32KeyIndex(0)); +final _testSideHd = _testAccountHd.childKey(Bip32KeyIndex(1)); diff --git a/cw_pivx/test/pivx_address_classify_test.dart b/cw_pivx/test/pivx_address_classify_test.dart new file mode 100644 index 0000000000..ba73d96455 --- /dev/null +++ b/cw_pivx/test/pivx_address_classify_test.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_pivx/src/pivx_network.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Guards the transparent send build path (cw_bitcoin/electrum_wallet.dart calls +/// RegexUtils.addressTypeFromStr). PIVX D... addresses are base58, not bech32; +/// before the bitcoin_base fork fix, this threw "Invalid bech32 format (string +/// is mixed case)" and blocked every t->t send. +void main() { + test('classifies a PIVX D... address as P2PKH without bech32-decoding it', () { + final hd = Bip32Slip10Secp256k1.fromSeed(Uint8List(64)) + .childKey(Bip32KeyIndex(0)); + final address = generateP2PKHAddress( + hd: hd, + index: 0, + network: PivxNetwork.mainnet, + ); + + final classified = + RegexUtils.addressTypeFromStr(address, PivxNetwork.mainnet); + expect(classified, isA()); + }); +} diff --git a/cw_pivx/test/pivx_bech32_repro_test.dart b/cw_pivx/test/pivx_bech32_repro_test.dart new file mode 100644 index 0000000000..ddb5ab4136 --- /dev/null +++ b/cw_pivx/test/pivx_bech32_repro_test.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_pivx/src/pivx_network.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// PIVX transparent addresses (base58, 'D' prefix, pubkey version byte 30) are +/// byte-identical to Dogecoin and have NO segwit form. Parsing one against a +/// segwit-capable network sends it down the segwit branch, which bech32-decodes +/// it and throws "Invalid bech32 format (string is mixed case)" — the error a +/// tester hit on a t->t send. Every PIVX address parse must use PivxNetwork. +void main() { + final hd = + Bip32Slip10Secp256k1.fromSeed(Uint8List(64)).childKey(Bip32KeyIndex(0)); + final pivxAddr = + generateP2PKHAddress(hd: hd, index: 0, network: PivxNetwork.mainnet); + + test('PivxNetwork classifies a D... address as P2PKH (no bech32 decode)', () { + expect(RegexUtils.addressTypeFromStr(pivxAddr, PivxNetwork.mainnet), + isA()); + }); + + test('a segwit-capable network wrongly bech32-decodes the same address', () { + // Documents the failure mode: this is why PIVX must never be parsed with a + // segwit network (e.g. BitcoinNetwork) — the app must keep it legacy-only. + expect( + () => RegexUtils.addressTypeFromStr(pivxAddr, BitcoinNetwork.mainnet), + throwsA(predicate((e) => e.toString().contains('bech32'))), + ); + }); +} diff --git a/cw_pivx/test/pivx_fee_policy_test.dart b/cw_pivx/test/pivx_fee_policy_test.dart new file mode 100644 index 0000000000..221790fe15 --- /dev/null +++ b/cw_pivx/test/pivx_fee_policy_test.dart @@ -0,0 +1,370 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:cw_pivx/src/sapling/sapling_constants.dart'; +import 'package:cw_pivx/src/sapling/sapling_factories.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/utils/tor/disabled.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PivxFeePolicy', () { + test('uses one transparent fee and dust policy', () { + expect(PivxFeePolicy.transparentDustThreshold, 5460); + expect(PivxFeePolicy.shieldedDustThreshold, 1446000); + expect( + PivxFeePolicy.dustThreshold, PivxFeePolicy.transparentDustThreshold); + expect(PivxFeePolicy.feeForSize(182), 10000); + expect( + PivxFeePolicy.feeForSize( + 182, + feePerKb: PivxFeePolicy.dustRelayFeePerKb, + ), + 5460, + ); + expect(PivxFeePolicy.transparentTxSize(1, 1), 192); + }); + + test('calculates Sapling fees from transaction size', () { + // A single real shielded output is padded to the 2-output minimum, so a + // 1-spend/1-output tx pays for two 948-byte outputs. + expect( + PivxFeePolicy.saplingFee(saplingInputs: 1, saplingOutputs: 1), + 2365000, + ); + // Two real outputs are already at the minimum; counted as-is. + expect( + PivxFeePolicy.saplingFee(saplingInputs: 1, saplingOutputs: 2), + 2365000, + ); + // A third real output is counted as-is. + expect( + PivxFeePolicy.saplingFee(saplingInputs: 1, saplingOutputs: 3), + 3313000, + ); + }); + + test('pads shielded outputs to the 2-output minimum for the deshield fee', + () { + // z->t: 1 spend + 1 shielded change + 1 transparent output. The builder + // pads the change to two shielded outputs, so the fee must cover both or + // the node rejects it as "insufficient fee" (the reported 1451000 < + // 2399000). 85 + 384 + 2*948 + 34 = 2399 bytes -> 2_399_000 zat. + expect( + PivxFeePolicy.saplingFee( + saplingInputs: 1, saplingOutputs: 1, transparentOutputs: 1), + 2399000, + ); + }); + + test('sizes the CompactSize count prefix so the fee is an exact upper ' + 'bound past 253 inputs', () { + // Below 253 elements every vector count is a 1-byte CompactSize; a single + // real shielded output is padded to the 2-output minimum. + expect(PivxFeePolicy.saplingTxSize(saplingInputs: 1, saplingOutputs: 1), + 85 + 384 + 2 * 948); + expect(PivxFeePolicy.saplingTxSize(saplingInputs: 252, saplingOutputs: 1), + 85 + 252 * 384 + 2 * 948); + // At 253 spends the count prefix grows to 3 bytes (+2). Without this the + // estimate under-counts the real tx by 2 bytes and the pinned fee is 2000 + // zat short — the network rejects a big sweep as "insufficient fee". + expect( + PivxFeePolicy.saplingTxSize(saplingInputs: 253, saplingOutputs: 1) - + PivxFeePolicy.saplingTxSize(saplingInputs: 252, saplingOutputs: 1), + 384 + 2, + ); + }); + + test('derives shielded dust threshold from PIVX Core formula', () { + final coreShieldedDust = PivxFeePolicy.saplingFeeFactor * + PivxFeePolicy.feeForSize( + PivxFeePolicy.saplingSpendSize + + PivxFeePolicy.transparentOutputSize + + 64, + feePerKb: PivxFeePolicy.dustRelayFeePerKb, + ); + + expect(coreShieldedDust, 1446000); + expect(PivxFeePolicy.shieldedDustThreshold, coreShieldedDust); + }); + }); + + group('SaplingParams', () { + test('uses the verified PIVX-hosted Sapling parameter metadata', () { + expect(SaplingParams.spendParamsSize, 47958396); + expect(SaplingParams.outputParamsSize, 3592860); + expect( + SaplingParams.spendParamsHash, + '8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13', + ); + expect( + SaplingParams.outputParamsHash, + '2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4', + ); + expect( + SaplingParams.spendParamsUrl, + 'https://duddino.com/sapling-spend.params', + ); + expect( + SaplingParams.outputParamsUrl, + 'https://duddino.com/sapling-output.params', + ); + }); + }); + + group('Sapling proving parameter download', () { + setUp(() { + CakeTor.instance = CakeTorDisabled(); + }); + + test('streams files to temp paths, verifies them, and renames finals', + () async { + final spendBytes = List.generate(257, (i) => i % 251); + final outputBytes = List.generate(113, (i) => (i * 3) % 251); + final server = await _serveParams( + spendBytes: spendBytes, + outputBytes: outputBytes, + ); + final dir = await Directory.systemTemp.createTemp('pivx_params_test_'); + final progress = []; + + try { + await SaplingTransactionBuilderWrapper.downloadProvingParamsToPath( + path: dir.path, + onProgress: progress.add, + spendParamsUrl: _serverUrl(server, SaplingParams.spendParamsFileName), + spendParamsSize: spendBytes.length, + spendParamsHash: sha256.convert(spendBytes).toString(), + outputParamsUrl: + _serverUrl(server, SaplingParams.outputParamsFileName), + outputParamsSize: outputBytes.length, + outputParamsHash: sha256.convert(outputBytes).toString(), + ); + + final spendFile = + File('${dir.path}/${SaplingParams.spendParamsFileName}'); + final outputFile = + File('${dir.path}/${SaplingParams.outputParamsFileName}'); + + expect(await spendFile.readAsBytes(), spendBytes); + expect(await outputFile.readAsBytes(), outputBytes); + expect(await File('${spendFile.path}.download').exists(), isFalse); + expect(await File('${outputFile.path}.download').exists(), isFalse); + expect(progress.last, 1.0); + } finally { + await server.close(force: true); + await dir.delete(recursive: true); + } + }); + + test('deletes temp files when verification fails', () async { + final spendBytes = List.filled(16, 7); + final outputBytes = List.filled(16, 9); + final server = await _serveParams( + spendBytes: spendBytes, + outputBytes: outputBytes, + ); + final dir = await Directory.systemTemp.createTemp('pivx_params_bad_'); + + try { + await expectLater( + SaplingTransactionBuilderWrapper.downloadProvingParamsToPath( + path: dir.path, + onProgress: (_) {}, + spendParamsUrl: + _serverUrl(server, SaplingParams.spendParamsFileName), + spendParamsSize: spendBytes.length, + spendParamsHash: '00', + outputParamsUrl: + _serverUrl(server, SaplingParams.outputParamsFileName), + outputParamsSize: outputBytes.length, + outputParamsHash: sha256.convert(outputBytes).toString(), + ), + throwsA(isA()), + ); + + final spendFile = + File('${dir.path}/${SaplingParams.spendParamsFileName}'); + expect(await spendFile.exists(), isFalse); + expect(await File('${spendFile.path}.download').exists(), isFalse); + } finally { + await server.close(force: true); + await dir.delete(recursive: true); + } + }); + }); + + group('SaplingTransactionBuilderWrapper note planning', () { + test('selects enough notes to cover amount and fee', () { + final selected = SaplingTransactionBuilderWrapper.selectNotesForAmount( + [ + {'value': 3000000}, + {'value': 500000}, + ], + 2000000, + ); + + expect(selected.length, 2); + }); + + test('deducts dust change into fee instead of creating dust output', () { + final noChangeFee = + PivxFeePolicy.saplingFee(saplingInputs: 1, saplingOutputs: 1); + const dustRemainder = 9000; + final plan = SaplingTransactionBuilderWrapper.planShieldedSpend( + totalInput: 2000000 + noChangeFee + dustRemainder, + amount: 2000000, + saplingInputs: 1, + ); + + expect(plan.canBuild, isTrue); + expect(plan.change, 0); + expect(plan.fee, noChangeFee + dustRemainder); + }); + + test('send-all spends all selected notes with no change', () { + final notes = [ + {'value': 3000000}, + {'value': 500000}, + ]; + + final selected = SaplingTransactionBuilderWrapper.selectNotesForAmount( + notes, + 2000000, + spendAll: true, + ); + + expect(selected.length, 2); + }); + + test('z-to-t spend plan uses transparent destination output size', () { + // 1 spend + 1 transparent vout; the shielded side is padded to the + // 2-output minimum: size = 85 + 384 + 2*948 + 34 = 2399 -> fee 2_399_000. + final expectedNoChangeFee = PivxFeePolicy.saplingFee( + saplingInputs: 1, + saplingOutputs: 0, + transparentOutputs: 1, + ); + final plan = SaplingTransactionBuilderWrapper.planShieldedSpend( + totalInput: 2000000 + expectedNoChangeFee, + amount: 2000000, + saplingInputs: 1, + transparentDestination: true, + ); + + expect(plan.canBuild, isTrue); + expect(plan.change, 0); + expect(plan.fee, expectedNoChangeFee); + // With output padding both shapes carry two shielded outputs, so the + // deshield costs more than a shielded->shielded spend by its extra + // transparent output. + expect( + expectedNoChangeFee, + greaterThan( + PivxFeePolicy.saplingFee(saplingInputs: 1, saplingOutputs: 1))); + }); + + test('z-to-t spend plan pays shielded change above dust', () { + final withChangeFee = PivxFeePolicy.saplingFee( + saplingInputs: 1, + saplingOutputs: 1, + transparentOutputs: 1, + ); + final change = PivxFeePolicy.shieldedDustThreshold + 1; + final plan = SaplingTransactionBuilderWrapper.planShieldedSpend( + totalInput: 2000000 + withChangeFee + change, + amount: 2000000, + saplingInputs: 1, + transparentDestination: true, + ); + + expect(plan.canBuild, isTrue); + expect(plan.change, change); + expect(plan.fee, withChangeFee); + }); + + test('t-to-z shield plan pays transparent change above dust', () { + final withChangeFee = PivxFeePolicy.saplingFee( + saplingOutputs: 1, + transparentInputs: 2, + transparentOutputs: 1, + ); + final change = PivxFeePolicy.transparentDustThreshold + 1; + final plan = SaplingTransactionBuilderWrapper.planShieldSpend( + totalInput: 2000000 + withChangeFee + change, + amount: 2000000, + transparentInputs: 2, + ); + + expect(plan.canBuild, isTrue); + expect(plan.change, change); + expect(plan.fee, withChangeFee); + }); + + test('t-to-z shield plan absorbs dust change into the fee', () { + final noChangeFee = PivxFeePolicy.saplingFee( + saplingOutputs: 1, + transparentInputs: 1, + ); + final dust = PivxFeePolicy.transparentDustThreshold; + final plan = SaplingTransactionBuilderWrapper.planShieldSpend( + totalInput: 2000000 + noChangeFee + dust, + amount: 2000000, + transparentInputs: 1, + ); + + expect(plan.canBuild, isTrue); + expect(plan.change, 0); + expect(plan.fee, noChangeFee + dust); + }); + + test('z-to-t dust change is absorbed into the fee', () { + final noChangeFee = PivxFeePolicy.saplingFee( + saplingInputs: 1, + saplingOutputs: 0, + transparentOutputs: 1, + ); + final dustRemainder = PivxFeePolicy.shieldedDustThreshold; + final plan = SaplingTransactionBuilderWrapper.planShieldedSpend( + totalInput: 2000000 + noChangeFee + dustRemainder, + amount: 2000000, + saplingInputs: 1, + transparentDestination: true, + ); + + expect(plan.canBuild, isTrue); + expect(plan.change, 0); + expect(plan.fee, noChangeFee + dustRemainder); + }); + }); +} + +Future _serveParams({ + required List spendBytes, + required List outputBytes, +}) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) { + final requestedFile = + request.uri.pathSegments.isEmpty ? '' : request.uri.pathSegments.last; + final bytes = requestedFile == SaplingParams.spendParamsFileName + ? spendBytes + : requestedFile == SaplingParams.outputParamsFileName + ? outputBytes + : null; + + if (bytes == null) { + request.response.statusCode = HttpStatus.notFound; + request.response.close(); + return; + } + + request.response.contentLength = bytes.length; + request.response.add(bytes); + request.response.close(); + }); + return server; +} + +String _serverUrl(HttpServer server, String filename) => + 'http://${InternetAddress.loopbackIPv4.address}:${server.port}/$filename'; diff --git a/cw_pivx/test/pivx_log_redaction_test.dart b/cw_pivx/test/pivx_log_redaction_test.dart new file mode 100644 index 0000000000..7866cde445 --- /dev/null +++ b/cw_pivx/test/pivx_log_redaction_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // Paths below are repo-root relative; `flutter test` runs with the package + // directory as CWD, so hop up one level in that case. + final repoRoot = Directory.current.path.endsWith('cw_pivx') ? '..' : '.'; + + group('PIVX sensitive log redaction', () { + final filesToScan = [ + 'cw_bitcoin/lib/electrum.dart', + 'cw_pivx/lib/src/pending_pivx_shielded_transaction.dart', + 'cw_pivx/lib/src/pivx_wallet.dart', + 'cw_pivx/lib/src/pivx_wallet_service.dart', + 'cw_pivx/lib/src/sapling/pivx_sapling_electrumx.dart', + 'cw_pivx/lib/src/sapling/sapling_factories.dart', + 'cw_pivx/lib/src/sapling/sapling_note_storage.dart', + 'lib/view_model/send/send_view_model.dart', + ]; + + final statementStart = RegExp(r'\b(?:print|printV)\s*\('); + final interpolation = RegExp(r'\$[A-Za-z_{]|toString\(\)'); + final sensitiveTerms = RegExp( + r'\b(seed|mnemonic|rseed|nullifier|cmu|witness|txid|anchor|' + r'address|balance|note|value|position|ciphertext|commitment)\b', + caseSensitive: false, + ); + + // Statements reviewed as sanitized: they interpolate only counts, enum + // reason/source codes, retry numbers, or booleans, never key, note, + // commitment, witness-node, or address material. Any edit to these lines + // changes the statement text and must be re-reviewed here. + final reviewedSanitizedStatements = { + r"printV( '[PIVX Sapling] Witness path has non-canonical node at index $invalidIndex; canonical_original=$originalCanonical/${path.length}, canonical_reversed=$reversedCanonical/${reversedPath.length}');", + r"printV('[PIVX Sapling] Witness accepted via $source');", + r"printV( '[PIVX Sapling] Witness attempt $label $retry/$retries failed: $reason');", + r"printV('[PIVX Sapling] Witness source summary: $witnessSourceSummary');", + r"printV( '[PIVX Sapling] Witness path shape: count=${witness.path.length}, first_chars=$firstPathLength, total_chars=${witnessHex.length}, hex=$isHexPath');", + }; + + String collectStatement(List lines, int startIndex) { + final buffer = StringBuffer(lines[startIndex].trim()); + for (var i = startIndex + 1; + i < lines.length && !buffer.toString().contains(');'); + i++) { + buffer.write(' ${lines[i].trim()}'); + } + return buffer.toString(); + } + + test('does not interpolate sensitive PIVX/Sapling metadata into logs', () { + final violations = []; + + for (final path in filesToScan) { + final file = File('$repoRoot/$path'); + expect(file.existsSync(), isTrue, reason: 'Missing scanned file $path'); + + final lines = file.readAsLinesSync(); + for (var i = 0; i < lines.length; i++) { + if (!statementStart.hasMatch(lines[i])) continue; + + final statement = collectStatement(lines, i); + if (interpolation.hasMatch(statement) && + sensitiveTerms.hasMatch(statement) && + !reviewedSanitizedStatements.contains(statement)) { + violations.add('$path:${i + 1}: $statement'); + } + } + } + + expect(violations, isEmpty); + }); + + test('invalid PIVX mnemonic errors stay generic', () { + final service = File('$repoRoot/cw_pivx/lib/src/pivx_wallet_service.dart') + .readAsStringSync(); + + expect(service, contains("throw Exception('Invalid PIVX mnemonic')")); + expect(service, isNot(contains('Invalid mnemonic:'))); + expect(service, isNot(contains(r'${credentials.mnemonic}'))); + }); + }); +} diff --git a/cw_pivx/test/pivx_proving_params_bundle_test.dart b/cw_pivx/test/pivx_proving_params_bundle_test.dart new file mode 100644 index 0000000000..5e7dc3d9ff --- /dev/null +++ b/cw_pivx/test/pivx_proving_params_bundle_test.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:cw_pivx/src/sapling/sapling_factories.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('writeAndVerifyProvingParam', () { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('pivx_params_test'); + }); + + tearDown(() { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + + test('writes the file when size + SHA256 match', () async { + final bytes = Uint8List.fromList(utf8.encode('pivx-sapling-synthetic')); + final hash = sha256.convert(bytes).toString(); + final dest = '${tmp.path}/sapling-spend.params'; + + await SaplingTransactionBuilderWrapper.writeAndVerifyProvingParam( + bytes: bytes, + destination: dest, + expectedSize: bytes.length, + expectedHash: hash, + ); + + expect(File(dest).existsSync(), isTrue); + expect(File(dest).readAsBytesSync(), equals(bytes)); + }); + + test('throws and removes the file on hash mismatch', () async { + final bytes = Uint8List.fromList(utf8.encode('pivx-sapling-synthetic')); + final dest = '${tmp.path}/sapling-output.params'; + + await expectLater( + SaplingTransactionBuilderWrapper.writeAndVerifyProvingParam( + bytes: bytes, + destination: dest, + expectedSize: bytes.length, + expectedHash: 'deadbeef' * 8, // wrong hash + ), + throwsA(isA()), + ); + + expect(File(dest).existsSync(), isFalse); + }); + + test('throws on size mismatch', () async { + final bytes = Uint8List.fromList(utf8.encode('pivx-sapling-synthetic')); + final hash = sha256.convert(bytes).toString(); + final dest = '${tmp.path}/sapling-spend.params'; + + await expectLater( + SaplingTransactionBuilderWrapper.writeAndVerifyProvingParam( + bytes: bytes, + destination: dest, + expectedSize: bytes.length + 1, + expectedHash: hash, + ), + throwsA(isA()), + ); + + expect(File(dest).existsSync(), isFalse); + }); + }); + + group('loadBundledParamOrNull', () { + test('returns null for an absent asset (build without bundled params)', + () async { + final result = await SaplingTransactionBuilderWrapper.loadBundledParamOrNull( + 'packages/cw_pivx/assets/params/does-not-exist.params', + ); + expect(result, isNull); + }); + }); +} diff --git a/cw_pivx/test/pivx_receive_page_options_test.dart b/cw_pivx/test/pivx_receive_page_options_test.dart new file mode 100644 index 0000000000..e0ee6c5759 --- /dev/null +++ b/cw_pivx/test/pivx_receive_page_options_test.dart @@ -0,0 +1,27 @@ +import 'package:cw_pivx/src/pivx_receive_page_options.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PivxReceivePageOption', () { + test('offers transparent and shielded, transparent first', () { + expect(PivxReceivePageOption.all, [ + PivxReceivePageOption.transparent, + PivxReceivePageOption.shieldedSapling, + ]); + }); + + test('maps options to address types and back', () { + expect(PivxReceivePageOption.transparent.toType(), PivxAddressType.transparent); + expect(PivxReceivePageOption.shieldedSapling.toType(), PivxAddressType.shieldedSapling); + expect(PivxReceivePageOption.fromType(PivxAddressType.transparent), + PivxReceivePageOption.transparent); + expect(PivxReceivePageOption.fromType(PivxAddressType.shieldedSapling), + PivxReceivePageOption.shieldedSapling); + }); + + test('labels match the picker text', () { + expect(PivxReceivePageOption.transparent.value, 'Transparent'); + expect(PivxReceivePageOption.shieldedSapling.value, 'Shielded (Sapling)'); + }); + }); +} diff --git a/cw_pivx/test/pivx_sapling_electrumx_test.dart b/cw_pivx/test/pivx_sapling_electrumx_test.dart new file mode 100644 index 0000000000..eec818e8d1 --- /dev/null +++ b/cw_pivx/test/pivx_sapling_electrumx_test.dart @@ -0,0 +1,1447 @@ +import 'dart:async'; + +import 'package:cw_pivx/src/sapling/pivx_sapling_electrumx.dart'; +import 'package:cw_pivx/src/sapling/sapling_factories.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Sentinel response: makes [FakeElectrumClient.call] return a future that never +/// completes, simulating a node that keeps the socket alive but never answers +/// the query. +class FakeHang { + const FakeHang(); +} + +class FakeElectrumClient { + FakeElectrumClient(this.responses); + + final List responses; + final Map errors = {}; + final calledMethods = []; + final calledParams = >[]; + int calls = 0; + int _id = 0; + + Future call({ + required String method, + List params = const [], + Function(int)? idCallback, + }) async { + calls++; + calledMethods.add(method); + calledParams.add(List.from(params)); + _id++; + idCallback?.call(_id); + final response = responses.removeAt(0); + if (response is FakeHang) { + return Completer().future; // never completes + } + if (response is FakeRpcError) { + errors[_id] = response.message; + return null; + } + if (response is Exception) { + throw response; + } + return response; + } + + String getErrorMessage(int id) => errors[id] ?? ''; +} + +class FakeRpcError { + FakeRpcError(this.message); + + final String message; +} + +/// Fake witness-root verifier that records calls; assignable to +/// [WitnessRootVerifier] through its call method. +class RecordingWitnessVerifier { + RecordingWitnessVerifier({this.result = true, this.error}); + + bool result; + Object? error; + final calls = >[]; + + bool call({ + required String witnessHex, + required String cmuHex, + required String anchorHex, + required int position, + }) { + calls.add({ + 'witnessHex': witnessHex, + 'cmuHex': cmuHex, + 'anchorHex': anchorHex, + 'position': position, + }); + if (error != null) throw error!; + return result; + } +} + +Map bestAnchorJson() => { + 'anchor': + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'height': 2701000, + }; + +Map nullifierUnspentJson() => {'spent': false}; + +Map commitmentMissingJson() => {'exists': false}; + +Map v1CapabilitiesJson({ + String contract = SaplingRpcCapabilities.v1ContractId, + List? methods, + Map? features, + Map? rangeResponseFormat, +}) => + { + 'contract': contract, + 'server_version': 'ElectrumX 1.19.0-pivx', + 'pivx_core_version': 'v5.6.1', + 'network': 'mainnet', + 'sapling_activation_height': SaplingActivation.mainnet, + 'max_block_range': 100, + 'methods': methods ?? + [ + 'blockchain.sapling.get_block_range', + 'blockchain.sapling.get_best_anchor', + 'blockchain.sapling.get_witness', + 'blockchain.sapling.get_nullifier_status', + 'blockchain.sapling.get_commitment_info', + ], + 'features': features ?? + { + 'global_output_positions': true, + 'block_hashes': true, + 'structured_errors': true, + }, + 'range_response_format': rangeResponseFormat ?? + { + 'global_output_positions': true, + 'block_hashes': true, + }, + }; + +// Real chainster v1 capture (pivx.sapling.electrumx.v1, hex_byte_order=display), +// global_position 0. cmu/anchor are display order; the serialization order the +// native crypto needs is the 32-byte reversal. +const chainsterCmuDisplay = + '219abc22220f9e133c4414d9462b9d86e3c8fb1b6ccda36ff0d919c5f6588a95'; +const chainsterCmuSerialization = + '958a58f6c519d9f06fa3cd6c1bfbc8e3869d2b46d914443c139e0f2222bc9a21'; +const chainsterAnchorDisplay = + '23ad2c39c720e69af6cf5c7cca8aa501d7a36964ba7d9755659b242fb6dd06db'; +const chainsterAnchorSerialization = + 'db06ddb62f249b6555977dba6469a3d701a58aca7c5ccff69ae620c7392cad23'; + +// The real 32-node leaf-to-root witness path for the note above (already +// serialization order, never reversed). +const chainsterWitnessPath = [ + '7352fa42ff23e572387ba965db04bdc6fd6cab74b97338c4c79948c6dc4bc33c', + 'ce75b04ebdcf92ea0cab93bf5fc2cd675fc867accacb42550f357950b8fc3a14', + '6875488967e1008d7fec44841dab10a7c244266bdb936a9fad10e798da1a5b39', + '76fe6c77f4f4603669b1159e519329f97744e69dcffef6b6266cf5c3c916eb31', + '61022337bf970d2de80803684e0fe6248c3c6a7ad581433ffda690cdc8ec0a42', + '938988a2c5c64733c988336bff7b5d8416277036363aeaad0968afffe665de1b', + '30d3896b4ead5b4c9db948361c6466acc6bc0a6d44af52b5ce75a107ff186b51', + 'ac787541cd73929dca61aff447c2995ac74ec0c59f3a769ce02553162ea9162c', + '3ec002c09ed73b1133790de0cf66a847ba5495e2568e0c05d4a07ce691b14d0a', + '273e391d61d8df4c83d402ed2e46702c81841092e3a9499bc72082d0c5fc241c', + 'e401f0174fefa0bd37301482536d9541ef16b48d2a5f75077bc9c55eaf35ac4e', + '53925b451d437417eb98769352a43b8456f444c7e6374a25d6872be946090134', + 'b9e09e33386178a9254c48f516a17321a282fba02d4b77bce690be8563ee3122', + '10c0eec61907cef40126df0126ff8d0605643116f62aaa6b8cc0b2839ed4af1e', + '49453ebd0c7871ff489ffc45714ef15cdd027053bcf94c4a64a220d473b7a10a', + 'af1e4b9097509e5be5765725c27ae59e0819e64649aee556c72d773b08ea500a', + '1ea6675f9551eeb9dfaaa9247bc9858270d3d3a4c5afa7177a984d5ed1be2451', + '6edb16d01907b759977d7650dad7e3ec049af1a3d875380b697c862c9ec5d51c', + 'cd1c8dbf6e3acc7a80439bc4962cf25b9dce7c896f3a5bd70803fc5a0e33cf00', + '6aca8448d8263e547d5ff2950e2ed3839e998d31cbc6ac9fd57bc6002b159216', + '8d5fa43e5a10d11605ac7430ba1f5d81fb1b68d29a640405767749e841527673', + '08eeab0c13abd6069e6310197bf80f9c1ea6de78fd19cbae24d4a520e6cf3023', + '0769557bc682b1bf308646fd0b22e648e8b9e98f57e29f5af40f6edb833e2c49', + '4c6937d78f42685f84b43ad3b7b00f81285662f85c6a68ef11d62ad1a3ee0850', + 'fee0e52802cb0c46b1eb4d376c62697f4759f6c8917fa352571202fd778fd712', + '16d6252968971a83da8521d65382e61f0176646d771c91528e3276ee45383e4a', + 'd2e1642c9a462229289e5b0e3b7f9008e0301cbb93385ee0e21da2545073cb58', + 'a5122c08ff9c161d9ca6fc462073396c7d7d38e8ee48cdb3bea7e2230134ed6a', + '28e7b841dcbc47cceb69d7cb8d94245fb7cb2ba3a7a6bc18f13f945f7dbd6e2a', + 'e1f34b034d4a3cd28557e2907ebf990c918f64ecb50a94f01d6fda5ca5c7ef72', + '12935f14b676509b81eb49ef25f39269ed72309238b4c145803544b646dca62d', + 'b2eed031d4d6a4f02a097f80b54cc1541d4163c6b6f5971f88b6e41d35c53814', +]; + +// Faithful trimmed shape of the real capabilities.json response. +Map chainsterV1CapabilitiesJson() => { + 'success': true, + 'contract': SaplingRpcCapabilities.v1ContractId, + 'server_version': 'ElectrumX 1.19.0', + 'pivx_core_version': 'PIVX Core:5.6.1', + 'network': 'mainnet', + 'sapling_activation_height': SaplingActivation.mainnet, + 'max_block_range': 100, + 'features': { + 'global_output_positions': true, + 'block_hashes': true, + 'structured_errors': true, + 'canonical_witnesses': true, + }, + 'release_contract_ready': true, + 'index_status': { + 'ready': true, + 'state': 'ready', + 'db_height': 5493846, + 'daemon_height': 5493846, + 'lag': 0, + 'retryable': false, + }, + 'hex_byte_order': 'display', + 'consensus_anchors': true, + 'range_error_types': [ + 'invalid_range', + 'daemon_error', + 'backend_timeout', + 'index_not_ready', + 'missing_block', + 'index_incomplete', + 'index_error', + 'unsupported_method', + 'server_error', + ], + 'witness_backend': + '/root/electrumx/contrib/pivx_sapling_witness/target/release/pivx_sapling_witness', + 'methods': [ + 'blockchain.sapling.get_block_range', + 'blockchain.sapling.get_best_anchor', + 'blockchain.sapling.get_witness', + 'blockchain.sapling.get_nullifier_status', + 'blockchain.sapling.get_commitment_info', + ], + }; + +void main() { + group('BestAnchorResult', () { + test('uses anchor_height instead of chain tip height when present', () { + final bestAnchor = BestAnchorResult.fromJson({ + 'anchor': + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'height': 5440981, + 'anchor_height': 5440977, + }); + + expect( + bestAnchor.anchor, + equals( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')); + expect(bestAnchor.height, equals(5440977)); + }); + }); + + group('SaplingRpcCapabilities', () { + test('classifies the complete v1 release contract as release ready', () { + final capabilities = + SaplingRpcCapabilities.fromJson(v1CapabilitiesJson()); + + expect(capabilities.supportsV1ReleaseContract, isTrue); + expect(capabilities.advertisesV1Contract, isTrue); + expect(capabilities.supportsBlockRange, isTrue); + expect(capabilities.supportsGlobalOutputPositions, isTrue); + expect(capabilities.supportsBestAnchor, isTrue); + expect(capabilities.supportsWitness, isTrue); + expect(capabilities.supportsBlockHashes, isTrue); + expect(capabilities.supportsStructuredErrors, isTrue); + }); + + test('does not classify partial v1 metadata as release ready', () { + final capabilities = SaplingRpcCapabilities.fromJson(v1CapabilitiesJson( + methods: [ + 'blockchain.sapling.get_block_range', + 'blockchain.sapling.get_witness', + ], + features: { + 'global_output_positions': true, + 'block_hashes': true, + 'structured_errors': true, + }, + )); + + expect(capabilities.advertisesV1Contract, isTrue); + expect(capabilities.supportsV1ReleaseContract, isFalse); + }); + + test('marks legacy fallback as compatibility only', () { + final capabilities = SaplingRpcCapabilities.legacyBlockRangeOnly(); + + expect(capabilities.supportsBlockRange, isTrue); + expect(capabilities.isLegacyBlockRangeOnly, isTrue); + expect(capabilities.supportsV1ReleaseContract, isFalse); + }); + + test('detects active-height index via method, flag, or neither', () { + final viaMethod = SaplingRpcCapabilities.fromJson(v1CapabilitiesJson( + methods: [ + 'blockchain.sapling.get_block_range', + 'blockchain.sapling.get_active_heights', + ], + )); + expect(viaMethod.supportsActiveHeights, isTrue); + + final viaFlag = SaplingRpcCapabilities.fromJson(v1CapabilitiesJson( + features: { + 'supports_active_height_index': true, + 'active_heights_max_limit': 50000, + }, + )); + expect(viaFlag.supportsActiveHeights, isTrue); + expect(viaFlag.activeHeightsMaxLimit, 50000); + + final absent = SaplingRpcCapabilities.fromJson(v1CapabilitiesJson()); + expect(absent.supportsActiveHeights, isFalse); + }); + }); + + group('PIVXSaplingElectrumX probeCapabilities', () { + test('accepts a complete v1 release contract', () async { + final client = FakeElectrumClient([ + v1CapabilitiesJson(), + bestAnchorJson(), + nullifierUnspentJson(), + commitmentMissingJson(), + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + final capabilities = await sapling.probeCapabilities(); + + expect(capabilities.supportsV1ReleaseContract, isTrue); + expect(client.calledMethods, [ + 'blockchain.sapling.capabilities', + 'blockchain.sapling.get_best_anchor', + 'blockchain.sapling.get_nullifier_status', + 'blockchain.sapling.get_commitment_info', + ]); + }); + + test('rejects an incomplete advertised v1 release contract', () async { + final client = FakeElectrumClient([ + v1CapabilitiesJson( + methods: ['blockchain.sapling.get_block_range'], + ) + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + await expectLater( + sapling.probeCapabilities(), + throwsA(isA()), + ); + }); + + test('rejects advertised v1 when live best-anchor helper fails', () async { + final client = FakeElectrumClient([ + v1CapabilitiesJson(), + FakeRpcError('internal server error'), + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + await expectLater( + sapling.probeCapabilities(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('live release method validation failed'), + ), + ), + ); + expect(client.calledMethods, [ + 'blockchain.sapling.capabilities', + 'blockchain.sapling.get_best_anchor', + ]); + }); + + test('falls back to legacy block-range compatibility only', () async { + final client = FakeElectrumClient([ + Exception('unknown method'), + Exception('method not found'), + >[], + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + final capabilities = await sapling.probeCapabilities(); + + expect(capabilities.isLegacyBlockRangeOnly, isTrue); + expect(capabilities.supportsBlockRange, isTrue); + expect(capabilities.supportsV1ReleaseContract, isFalse); + expect(client.calls, equals(3)); + }); + + test('tries the legacy capability alias after primary server error', + () async { + final client = FakeElectrumClient([ + FakeRpcError('internal server error'), + v1CapabilitiesJson(), + bestAnchorJson(), + nullifierUnspentJson(), + commitmentMissingJson(), + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + final capabilities = await sapling.probeCapabilities(); + + expect(capabilities.supportsV1ReleaseContract, isTrue); + expect(client.calledMethods, [ + 'blockchain.sapling.capabilities', + 'blockchain.sapling.get_capabilities', + 'blockchain.sapling.get_best_anchor', + 'blockchain.sapling.get_nullifier_status', + 'blockchain.sapling.get_commitment_info', + ]); + }); + + test('does not hide non-capability server errors behind fallbacks', + () async { + final client = FakeElectrumClient([ + FakeRpcError('internal server error'), + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + expect( + sapling.getBlockRange(2700500, endHeight: 2700500), + throwsA(isA()), + ); + expect(client.calls, equals(1)); + }); + + test('retries then rejects a persistently malformed null response', + () async { + final client = FakeElectrumClient([null, null, null]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + await expectLater( + sapling.probeCapabilities(), + throwsA(isA()), + ); + expect(client.calls, equals(3)); // one probe per retry attempt + }); + + test('retries a transient incomplete capabilities payload', () async { + // First probe comes back without get_block_range (a reconnect blip); the + // retry gets the real caps. A blip must not read as an unsupported node. + final client = FakeElectrumClient([ + {'methods': []}, + v1CapabilitiesJson(), + bestAnchorJson(), + nullifierUnspentJson(), + commitmentMissingJson(), + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + final capabilities = await sapling.probeCapabilities(); + + expect(capabilities.supportsBlockRange, isTrue); + expect(capabilities.supportsV1ReleaseContract, isTrue); + }); + }); + + group('PIVXSaplingElectrumX getBlockRange', () { + test('accepts complete empty v1 envelopes', () async { + final client = FakeElectrumClient([ + { + 'from_height': 2700500, + 'to_height': 2700599, + 'complete': true, + 'block_hashes': { + '2700500': 'hash_a', + '2700501': 'hash_b', + }, + 'blocks': >[], + } + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + final result = + await sapling.getBlockRangeResult(2700500, endHeight: 2700599); + + expect(result.blocks, isEmpty); + expect(result.blockHashes[2700500], equals('hash_a')); + expect(result.blockHashes[2700501], equals('hash_b')); + }); + + test('rejects incomplete v1 envelopes', () async { + final client = FakeElectrumClient([ + { + 'from_height': 2700500, + 'to_height': 2700599, + 'complete': false, + 'blocks': >[], + } + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + expect( + sapling.getBlockRange(2700500, endHeight: 2700599), + throwsA(isA()), + ); + }); + + test('rejects mismatched v1 envelope ranges', () async { + final client = FakeElectrumClient([ + { + 'from_height': 2700501, + 'to_height': 2700599, + 'complete': true, + 'blocks': >[], + } + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + expect( + sapling.getBlockRange(2700500, endHeight: 2700599), + throwsA(isA()), + ); + }); + + test('wraps malformed block entries with block-range context', () async { + final client = FakeElectrumClient([ + { + 'from_height': 2700500, + 'to_height': 2700500, + 'complete': true, + 'blocks': [ + {'height': 2700500} + ], + } + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + expect( + sapling.getBlockRange(2700500, endHeight: 2700500), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('get_block_range returned malformed block data'), + ), + ), + ); + }); + }); + + group('PIVXSaplingElectrumX syncBlocks', () { + test('does not complete a failed range', () async { + final client = FakeElectrumClient([ + Exception('daemon unavailable'), + Exception('daemon unavailable'), + Exception('daemon unavailable'), + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + final completedRanges = []; + + await expectLater( + sapling.syncBlocks( + fromHeight: 2700500, + toHeight: 2700500, + parallelBatches: 1, + onBatch: (_) async {}, + onRangeComplete: (rangeStart, rangeEnd, blockHashes) async { + completedRanges.add('$rangeStart-$rangeEnd'); + }, + ), + throwsA(isA()), + ); + + expect(completedRanges, isEmpty); + expect(client.calls, equals(3)); + }); + + test('active-height index scans only active windows and reaches toHeight', + () async { + // 4 windows in [2700500,2700899]; only 2 hold Sapling activity. + final client = FakeElectrumClient([ + { + 'heights': [2700550, 2700720], + 'start': 2700500, + 'end': 2700899, + 'complete': true, + 'db_height': 2700899, + }, + { + 'from_height': 2700500, + 'to_height': 2700599, + 'complete': true, + 'blocks': >[], + }, + { + 'from_height': 2700700, + 'to_height': 2700799, + 'complete': true, + 'blocks': >[], + }, + ]); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + capabilities: SaplingRpcCapabilities.fromJson(v1CapabilitiesJson( + methods: [ + 'blockchain.sapling.get_block_range', + 'blockchain.sapling.get_active_heights', + ], + )), + ); + final completedRanges = []; + + await sapling.syncBlocks( + fromHeight: 2700500, + toHeight: 2700899, + parallelBatches: 1, + onBatch: (_) async {}, + onRangeComplete: (rangeStart, rangeEnd, _) async { + completedRanges.add('$rangeStart-$rangeEnd'); + }, + ); + + // Empty windows [2700600-99] and [2700800-99] are never fetched. + expect( + client.calledMethods + .where((m) => m.contains('get_block_range')) + .length, + 2, + ); + // Active windows scanned in order, then cursor advanced to toHeight. + expect(completedRanges, [ + '2700500-2700599', + '2700700-2700799', + '2700500-2700899', + ]); + }); + + test('ends the pass instead of hanging when a range stalls', () { + fakeAsync((async) { + // Node keeps the socket alive but never answers get_block_range. + final client = FakeElectrumClient([const FakeHang()]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + final completedRanges = []; + var returned = false; + + sapling + .syncBlocks( + fromHeight: 2700500, + toHeight: 2700500, + parallelBatches: 1, + onBatch: (_) async {}, + onRangeComplete: (rangeStart, rangeEnd, blockHashes) async { + completedRanges.add('$rangeStart-$rangeEnd'); + }, + ) + .then((_) => returned = true); + + // Before the fetch timeout the pass is still waiting on the node. + async.elapse(const Duration(seconds: 5)); + expect(returned, isFalse); + + // Past the timeout the stall maps to a graceful pass-end, not a hang. + async.elapse(kSaplingBlockRangeFetchTimeout); + async.flushMicrotasks(); + expect(returned, isTrue); + expect(completedRanges, isEmpty); + }); + }); + + test('logs first, checkpoint, and final shield sync ranges only', () { + expect( + shouldLogPivxShieldSyncCheckpoint( + rangeStart: 5440400, + rangeEnd: 5440499, + startHeight: 5440400, + targetHeight: 5451000, + ), + isTrue, + ); + expect( + shouldLogPivxShieldSyncCheckpoint( + rangeStart: 5440500, + rangeEnd: 5440599, + startHeight: 5440400, + targetHeight: 5451000, + ), + isFalse, + ); + expect( + shouldLogPivxShieldSyncCheckpoint( + rangeStart: 5449901, + rangeEnd: 5450000, + startHeight: 5440400, + targetHeight: 5451000, + ), + isTrue, + ); + expect( + shouldLogPivxShieldSyncCheckpoint( + rangeStart: 5451000, + rangeEnd: 5451000, + startHeight: 5440400, + targetHeight: 5451000, + ), + isTrue, + ); + }); + }); + + group('PIVXSaplingElectrumX getAnchorBoundWitness', () { + const anchorHex = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const commitmentHex = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + test('accepts witness bound to selected anchor and commitment', () async { + final client = FakeElectrumClient([ + { + 'position': 42, + 'path': [ + '0100000000000000000000000000000000000000000000000000000000000000' + ], + 'anchor': anchorHex, + 'anchor_height': 2700600, + 'commitment': commitmentHex, + } + ]); + final verifier = RecordingWitnessVerifier(); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: verifier.call, + ); + + final witness = await sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ); + + expect(witness.position, equals(42)); + expect(witness.anchor, equals(anchorHex)); + expect(witness.anchorHeight, equals(2700600)); + expect(witness.commitment, equals(commitmentHex)); + expect(witness.source, equals(SaplingWitnessResult.sourceAnchorBound)); + expect(client.calledMethods, contains('blockchain.sapling.get_witness')); + expect(client.calledParams.single, equals([commitmentHex, anchorHex])); + + // Root verification must have run against the selected spend anchor + // with the full padded witness path. + final call = verifier.calls.single; + expect(call['cmuHex'], equals(commitmentHex)); + expect(call['anchorHex'], equals(anchorHex)); + expect(call['position'], equals(42)); + expect( + (call['witnessHex'] as String).length, + equals(SaplingWitnessResult.saplingTreeDepth * + SaplingWitnessResult.saplingNodeHexLength), + ); + }); + + test('falls back to commitment-only witness with server-selected anchor', + () async { + final client = FakeElectrumClient([ + FakeRpcError('witness not found for 44757'), + { + 'position': 44757, + 'path': [ + '0100000000000000000000000000000000000000000000000000000000000000' + ], + 'anchor': + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + 'anchor_height': 2700598, + 'commitment': commitmentHex, + } + ]); + final verifier = RecordingWitnessVerifier(); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: verifier.call, + ); + + final witness = await sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + notePosition: 44757, + ); + + expect(witness.position, equals(44757)); + expect( + witness.anchor, + equals( + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd')); + expect(witness.anchorHeight, equals(2700598)); + expect(witness.source, + equals(SaplingWitnessResult.sourceCommitmentOnlyFallback)); + expect(client.calledParams, [ + [commitmentHex, anchorHex], + [commitmentHex], + ]); + + // The commitment-only fallback spends against the server-selected + // witness anchor, so the root must be verified against that anchor. + final call = verifier.calls.single; + expect( + call['anchorHex'], + equals( + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd')); + expect(call['cmuHex'], equals(commitmentHex)); + expect(call['position'], equals(44757)); + }); + + test('sanitizes failed real-note witness attempt diagnostics', () async { + final client = FakeElectrumClient([ + FakeRpcError( + 'canonical_witness_unavailable for $commitmentHex at $anchorHex'), + FakeRpcError('commitment not found: $commitmentHex'), + FakeRpcError('method not found for anchor $anchorHex'), + ]); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: RecordingWitnessVerifier().call, + ); + + await expectLater( + sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ), + throwsA( + isA() + .having( + (error) => error.message, + 'message', + contains('commitment_anchor:canonical_witness_unavailable'), + ) + .having( + (error) => error.message, + 'message', + contains('commitment_only:canonical_witness_unavailable'), + ) + .having( + (error) => error.message, + 'message', + isNot(contains(commitmentHex)), + ) + .having( + (error) => error.message, + 'message', + isNot(contains(anchorHex)), + ), + ), + ); + expect(client.calledParams, [ + [commitmentHex, anchorHex], + [commitmentHex], + [commitmentHex], + ]); + }); + + test('normalizes map-shaped witness path elements', () async { + final client = FakeElectrumClient([ + { + 'position': 42, + 'path': [ + { + 'hash': + '0100000000000000000000000000000000000000000000000000000000000000' + } + ], + 'anchor': anchorHex, + 'anchor_height': 2700600, + 'commitment': commitmentHex, + } + ]); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: RecordingWitnessVerifier().call, + ); + + final witness = await sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ); + + expect(witness.path, hasLength(SaplingWitnessResult.saplingTreeDepth)); + expect(witness.path.first, + '0100000000000000000000000000000000000000000000000000000000000000'); + expect(witness.path[1], + '817de36ab2d57feb077634bca77819c8e0bd298c04f6fed0e6a83cc1356ca155'); + }); + + test('corrects big-endian witness path elements', () async { + final client = FakeElectrumClient([ + { + 'position': 42, + 'path': [ + '0000000000000000000000000000000000000000000000000000000000000080' + ], + 'anchor': anchorHex, + 'anchor_height': 2700600, + 'commitment': commitmentHex, + } + ]); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: RecordingWitnessVerifier().call, + ); + + final witness = await sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ); + + expect(witness.path.first, + '8000000000000000000000000000000000000000000000000000000000000000'); + expect(witness.path, hasLength(SaplingWitnessResult.saplingTreeDepth)); + }); + + test('rejects commitment-only witness for a different commitment', + () async { + final client = FakeElectrumClient([ + FakeRpcError('witness not found for anchor'), + { + 'position': 42, + 'path': [ + '0100000000000000000000000000000000000000000000000000000000000000' + ], + 'anchor': anchorHex, + 'anchor_height': 2700600, + 'commitment': + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + } + ]); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: RecordingWitnessVerifier().call, + ); + + expect( + sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + notePosition: 42, + ), + throwsA(isA()), + ); + }); + + test('rejects witness for a different anchor root', () async { + final client = FakeElectrumClient([ + { + 'position': 42, + 'path': [ + '0100000000000000000000000000000000000000000000000000000000000000' + ], + 'anchor': + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + 'anchor_height': 2700600, + 'commitment': commitmentHex, + }, + FakeRpcError('witness not found'), + ]); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: RecordingWitnessVerifier().call, + ); + + expect( + sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ), + throwsA(isA()), + ); + }); + + test('rejects witness without anchor metadata', () async { + final client = FakeElectrumClient([ + { + 'position': 42, + 'path': [ + '0100000000000000000000000000000000000000000000000000000000000000' + ], + 'commitment': commitmentHex, + } + ]); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: RecordingWitnessVerifier().call, + ); + + expect( + sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ), + throwsA(isA()), + ); + }); + + test('rejects witness for a different commitment', () async { + final client = FakeElectrumClient([ + { + 'position': 42, + 'path': [ + '0100000000000000000000000000000000000000000000000000000000000000' + ], + 'anchor': anchorHex, + 'anchor_height': 2700600, + 'commitment': + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + } + ]); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: RecordingWitnessVerifier().call, + ); + + expect( + sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ), + throwsA(isA()), + ); + }); + + Map witnessJson({String? anchor}) => { + 'position': 42, + 'path': [ + '0100000000000000000000000000000000000000000000000000000000000000' + ], + 'anchor': anchor ?? anchorHex, + 'anchor_height': 2700600, + 'commitment': commitmentHex, + }; + + test( + 'rejects tampered witness with witness_root_mismatch on both attempt paths', + () async { + // Valid-shaped responses for the commitment_anchor attempt and both + // commitment_only retries; only the local root recomputation fails. + final client = FakeElectrumClient([ + witnessJson(), + witnessJson(), + witnessJson(), + ]); + final verifier = RecordingWitnessVerifier(result: false); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: verifier.call, + ); + + await expectLater( + sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ), + throwsA( + isA() + .having( + (error) => error.message, + 'message', + contains('commitment_anchor:witness_root_mismatch'), + ) + .having( + (error) => error.message, + 'message', + contains('commitment_only:witness_root_mismatch'), + ), + ), + ); + // Verification ran on every attempt: 1 anchor-bound + 2 fallback retries. + expect(verifier.calls, hasLength(3)); + }); + + test('rejects witness when root verification itself errors (fail closed)', + () async { + final client = FakeElectrumClient([ + witnessJson(), + witnessJson(), + witnessJson(), + ]); + final verifier = RecordingWitnessVerifier( + error: StateError('Witness root verification error: native failure'), + ); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: verifier.call, + ); + + await expectLater( + sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('witness_root_mismatch'), + ), + ), + ); + expect(verifier.calls, hasLength(3)); + }); + + test('verifies the root before accepting a commitment-only fallback', + () async { + final serverAnchor = + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'; + final client = FakeElectrumClient([ + FakeRpcError('witness not found'), + witnessJson(anchor: serverAnchor), + witnessJson(anchor: serverAnchor), + ]); + final verifier = RecordingWitnessVerifier(result: false); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: verifier.call, + ); + + await expectLater( + sapling.getAnchorBoundWitness( + commitment: commitmentHex, + anchor: BestAnchorResult(anchor: anchorHex, height: 2700600), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('commitment_only:witness_root_mismatch'), + ), + ), + ); + // Both fallback retries verified against the server-selected anchor. + expect(verifier.calls, hasLength(2)); + for (final call in verifier.calls) { + expect(call['anchorHex'], equals(serverAnchor)); + } + }); + }); + + group('v1 display byte order', () { + test('parses hex_byte_order and canonical witness metadata (fixture)', () { + final capabilities = + SaplingRpcCapabilities.fromJson(chainsterV1CapabilitiesJson()); + + expect(capabilities.hexByteOrder, equals('display')); + expect(capabilities.usesDisplayByteOrder, isTrue); + expect(capabilities.canonicalWitnesses, isTrue); + expect(capabilities.consensusAnchors, isTrue); + expect(capabilities.indexStatus?['ready'], isTrue); + expect(capabilities.rangeErrorTypes, contains('index_incomplete')); + }); + + test('canonicalWitnesses requires both feature flag and witness backend', + () { + final noBackend = SaplingRpcCapabilities.fromJson( + chainsterV1CapabilitiesJson()..remove('witness_backend'), + ); + expect(noBackend.canonicalWitnesses, isFalse); + + final json = chainsterV1CapabilitiesJson(); + (json['features'] as Map)['canonical_witnesses'] = false; + final noFeature = SaplingRpcCapabilities.fromJson(json); + expect(noFeature.canonicalWitnesses, isFalse); + }); + + test('non-display order default leaves usesDisplayByteOrder false', () { + final capabilities = SaplingRpcCapabilities.fromJson(v1CapabilitiesJson()); + expect(capabilities.usesDisplayByteOrder, isFalse); + expect(capabilities.hexByteOrder, isNull); + }); + + test('reverseSaplingHexBytes maps display cmu to serialization and back', + () { + expect(reverseSaplingHexBytes(chainsterCmuDisplay), + equals(chainsterCmuSerialization)); + expect(reverseSaplingHexBytes(chainsterAnchorDisplay), + equals(chainsterAnchorSerialization)); + // Round-trips. + expect( + reverseSaplingHexBytes(reverseSaplingHexBytes(chainsterCmuDisplay)), + equals(chainsterCmuDisplay)); + }); + + test( + 'getAnchorBoundWitness feeds serialization-order cmu/anchor to the ' + 'verifier on a display node', () async { + final client = FakeElectrumClient([ + { + 'position': 0, + 'path': chainsterWitnessPath, + 'anchor': chainsterAnchorDisplay, + 'anchor_height': 5493519, + 'commitment': chainsterCmuDisplay, + } + ]); + final verifier = RecordingWitnessVerifier(); + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: verifier.call, + capabilities: const SaplingRpcCapabilities( + supportsBlockRange: true, + supportsGlobalOutputPositions: true, + supportsBestAnchor: true, + supportsWitness: true, + canonicalWitnesses: true, + hexByteOrder: 'display', + ), + ); + + final witness = await sapling.getAnchorBoundWitness( + commitment: chainsterCmuDisplay, + anchor: BestAnchorResult( + anchor: chainsterAnchorDisplay, height: 5493519), + notePosition: 0, + ); + + // Request + response validation stay in display order (server contract). + expect(client.calledParams.single, + equals([chainsterCmuDisplay, chainsterAnchorDisplay])); + expect(witness.commitment, equals(chainsterCmuDisplay)); + expect(witness.anchor, equals(chainsterAnchorDisplay)); + + // The crypto verifier must receive the reversed (serialization) bytes, + // and the path must be handed over untouched. + final call = verifier.calls.single; + expect(call['cmuHex'], equals(chainsterCmuSerialization)); + expect(call['anchorHex'], equals(chainsterAnchorSerialization)); + expect(call['witnessHex'], equals(chainsterWitnessPath.join())); + expect(call['position'], equals(0)); + }); + + test('non-display node passes cmu/anchor through unreversed', () async { + final client = FakeElectrumClient([ + { + 'position': 0, + 'path': chainsterWitnessPath, + 'anchor': chainsterAnchorDisplay, + 'anchor_height': 5493519, + 'commitment': chainsterCmuDisplay, + } + ]); + final verifier = RecordingWitnessVerifier(); + // No capabilities -> default (non-display), preserving legacy behavior. + final sapling = PIVXSaplingElectrumX( + electrumClient: client, + witnessRootVerifier: verifier.call, + ); + + await sapling.getAnchorBoundWitness( + commitment: chainsterCmuDisplay, + anchor: BestAnchorResult( + anchor: chainsterAnchorDisplay, height: 5493519), + notePosition: 0, + ); + + final call = verifier.calls.single; + expect(call['cmuHex'], equals(chainsterCmuDisplay)); + expect(call['anchorHex'], equals(chainsterAnchorDisplay)); + }); + }); + + group('SaplingTreeState', () { + test('parses the real v1 tree_state fixture without nullifier_count', () { + final treeState = SaplingTreeState.fromJson({ + 'success': true, + 'contract': SaplingRpcCapabilities.v1ContractId, + 'height': 2701500, + 'block_hash': + 'bead1714c264a73c113ab507f83dab12e7e4b17ab60ea01552ac1bf30783aec6', + 'anchor': chainsterAnchorDisplay, + 'root': chainsterAnchorDisplay, + 'latest_anchor': chainsterAnchorDisplay, + 'anchor_first_height': 2701424, + 'tree_size': 118, + 'commitment_count': 118, + 'indexed_height': 5493846, + 'sapling_activation_height': 2700500, + }); + + expect(treeState.anchor, equals(chainsterAnchorDisplay)); + expect(treeState.root, equals(chainsterAnchorDisplay)); + expect(treeState.treeSize, equals(118)); + expect(treeState.commitmentCount, equals(118)); + expect(treeState.indexedHeight, equals(5493846)); + expect(treeState.anchorFirstHeight, equals(2701424)); + expect(treeState.saplingActivationHeight, equals(2700500)); + expect(treeState.height, equals(2701500)); + }); + }); + + group('PIVXSaplingElectrumX getBlockRange v1 error envelopes', () { + test('classifies index_incomplete as retryable', () async { + final client = FakeElectrumClient([ + { + 'success': false, + 'complete': false, + 'start_height': 2700500, + 'end_height': 2700599, + 'error': {'type': 'index_incomplete', 'message': 'not indexed yet'}, + } + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + await expectLater( + sapling.getBlockRangeResult(2700500, endHeight: 2700599), + throwsA(isA()), + ); + }); + + test('treats a hard error type as a non-retryable failure', () async { + final client = FakeElectrumClient([ + { + 'success': false, + 'complete': false, + 'error': {'type': 'daemon_error'}, + } + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + await expectLater( + sapling.getBlockRangeResult(2700500, endHeight: 2700599), + throwsA(allOf( + isA(), + isNot(isA()), + )), + ); + }); + + test('treats index_not_ready as a retryable range error', () async { + final client = FakeElectrumClient([ + { + 'success': false, + 'error': {'type': 'index_not_ready', 'indexed_height': 99}, + }, + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + await expectLater( + sapling.getBlockRangeResult(100, endHeight: 199), + throwsA(isA()), + ); + }); + }); + + group('syncBlocks ceiling and cancellation', () { + test('processes the prefix and stops at the indexed ceiling without failing', + () async { + // First batch is served; the second is above the node's indexed ceiling. + final client = FakeElectrumClient([ + { + 'from_height': 100, + 'to_height': 100, + 'complete': true, + 'blocks': >[], + }, + { + 'success': false, + 'error': {'type': 'index_incomplete', 'indexed_height': 100}, + }, + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + final ranges = []; + await sapling.syncBlocks( + fromHeight: 100, + toHeight: 101, + batchSize: 1, + parallelBatches: 1, + onBatch: (_) async {}, + onRangeComplete: (start, end, _) async => ranges.add('$start-$end'), + ); + + // Below-ceiling range completed; the pass stopped at the ceiling instead + // of throwing and repolling. + expect(ranges, ['100-100']); + }); + + test('stops issuing requests once shouldCancel returns true', () async { + final client = FakeElectrumClient([ + { + 'from_height': 100, + 'to_height': 100, + 'complete': true, + 'blocks': >[], + }, + // A second response is intentionally not queued: a second request would + // throw, proving cancellation prevented it. + ]); + final sapling = PIVXSaplingElectrumX(electrumClient: client); + + var rounds = 0; + await sapling.syncBlocks( + fromHeight: 100, + toHeight: 101, + batchSize: 1, + parallelBatches: 1, + onBatch: (_) async {}, + onRangeComplete: (_, __, ___) async => rounds++, + shouldCancel: () => rounds >= 1, + ); + + expect(rounds, 1); + expect(client.calls, 1); + }); + }); + + group('SaplingRpcCapabilities index status', () { + test('exposes db_height and daemon_height', () { + final capabilities = SaplingRpcCapabilities.fromJson({ + 'index_status': { + 'db_height': 3100000, + 'daemon_height': 3100003, + 'lag': 3, + }, + }); + + expect(capabilities.indexHeight, 3100000); + expect(capabilities.daemonHeight, 3100003); + }); + + test('reports null heights when index_status is absent', () { + final capabilities = SaplingRpcCapabilities.fromJson(const {}); + + expect(capabilities.indexHeight, isNull); + expect(capabilities.daemonHeight, isNull); + }); + }); + + group('computeActiveWindows', () { + test('collapses heights in a window; aligned, ascending, deduped', () { + final windows = PIVXSaplingElectrumX.computeActiveWindows( + 1000, + 1500, + 100, + [1005, 1042, 1099, 1310, 1300], // unordered; each trio shares a window + ); + expect(windows, [ + [1000, 1099], + [1300, 1399], + ]); + }); + + test('clamps the final window to toHeight and drops out-of-range heights', + () { + final windows = PIVXSaplingElectrumX.computeActiveWindows( + 2000, + 2050, + 100, + [1999, 2010, 2075], // 1999 below range, 2075 above range + ); + expect(windows, [ + [2000, 2050], // clamped to toHeight, not 2099 + ]); + }); + + test('no active heights yields no windows', () { + expect( + PIVXSaplingElectrumX.computeActiveWindows(1000, 2000, 100, const []), + isEmpty, + ); + }); + }); +} diff --git a/cw_pivx/test/pivx_shielded_note_reservation_test.dart b/cw_pivx/test/pivx_shielded_note_reservation_test.dart new file mode 100644 index 0000000000..83134c02c2 --- /dev/null +++ b/cw_pivx/test/pivx_shielded_note_reservation_test.dart @@ -0,0 +1,469 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/bitcoin_address_record.dart'; +import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; +import 'package:cw_bitcoin/electrum.dart' as electrum; +import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_bitcoin/exceptions.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/cake_hive.dart'; +import 'package:cw_core/db/sqlite.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/unspent_coins_info.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_pivx/src/pending_pivx_shielded_transaction.dart'; +import 'package:cw_pivx/src/pivx_network.dart'; +import 'package:cw_pivx/src/pivx_transaction_priority.dart'; +import 'package:cw_pivx/src/pivx_wallet.dart'; +import 'package:cw_pivx/src/sapling/sapling_factories.dart'; +import 'package:cw_pivx/src/sapling/sapling_note_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _MockPathProviderPlatform extends Fake + with MockPlatformInterfaceMixin + implements PathProviderPlatform { + @override + Future getApplicationDocumentsPath() async { + return Directory.systemTemp.path; + } +} + +class _FakeEncryptionFileUtils extends EncryptionFileUtils { + @override + Future write({ + required String path, + required String password, + required String data, + }) async {} + + @override + Future read({ + required String path, + required String password, + }) async { + throw UnimplementedError(); + } +} + +class _FakeBroadcastElectrumClient extends electrum.ElectrumClient { + _FakeBroadcastElectrumClient({required this.broadcastResponse}); + + /// Response to return from broadcast; an empty string simulates a rejected + /// broadcast (double spend, network error, ...). + String broadcastResponse; + int broadcastCalls = 0; + + @override + Future broadcastTransaction({ + required String transactionRaw, + BasedUtxoNetwork? network, + Function(int)? idCallback, + }) async { + broadcastCalls++; + idCallback?.call(1); + return broadcastResponse; + } + + @override + String getErrorMessage(int id) => 'bad-txns-nullifier-double-spent'; +} + +final _lockedError = throwsA(isA().having( + (e) => e.toString(), + 'message', + contains(PivxWalletBase.shieldedNotesLockedMessage), +)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory dbDir; + late Directory hiveDir; + late Box unspentCoinsInfo; + var dbInitialized = false; + + setUpAll(() async { + PathProviderPlatform.instance = _MockPathProviderPlatform(); + SharedPreferences.setMockInitialValues({}); + dbDir = await Directory.systemTemp.createTemp('pivx_reservation_db_'); + hiveDir = await Directory.systemTemp.createTemp('pivx_reservation_hive_'); + databaseFactory = databaseFactoryFfi; + await initDb(pathOverride: '${dbDir.path}/cake.db'); + CakeHive.init(hiveDir.path); + if (!CakeHive.isAdapterRegistered(UnspentCoinsInfo.typeId)) { + CakeHive.registerAdapter(UnspentCoinsInfoAdapter()); + } + unspentCoinsInfo = await CakeHive.openBox( + '${UnspentCoinsInfo.boxName}_pivx_reservation_test', + ); + dbInitialized = true; + }); + + tearDownAll(() async { + await unspentCoinsInfo.close(); + if (dbInitialized) { + await db?.close(); + } + if (await hiveDir.exists()) { + await hiveDir.delete(recursive: true); + } + if (await dbDir.exists()) { + await dbDir.delete(recursive: true); + } + }); + + PivxWallet testWallet(electrum.ElectrumClient electrumClient) => + _testWallet( + unspentCoinsInfo: unspentCoinsInfo, + electrumClient: electrumClient, + ); + + group('shielded note reservations', () { + test('two sequential builds cannot reserve overlapping notes', () { + final wallet = testWallet(_FakeBroadcastElectrumClient( + broadcastResponse: '', + )); + + wallet.reserveShieldedNotes('tx1', ['n1', 'n2']); + + // Second pending transaction touching a reserved note fails with the + // locked-funds flavor of the insufficient shielded funds error. + expect( + () => wallet.reserveShieldedNotes('tx2', ['n2', 'n3']), + _lockedError, + ); + // The failed attempt must not leave a partial reservation behind. + expect(wallet.reservedShieldedNullifiers, {'n1', 'n2'}); + + // A build over disjoint notes succeeds. + wallet.reserveShieldedNotes('tx2', ['n3', 'n4']); + expect(wallet.reservedShieldedNullifiers, {'n1', 'n2', 'n3', 'n4'}); + }); + + test('release is idempotent and only affects the released transaction', + () { + final wallet = testWallet(_FakeBroadcastElectrumClient( + broadcastResponse: '', + )); + + wallet.reserveShieldedNotes('tx1', ['n1']); + wallet.reserveShieldedNotes('tx2', ['n2']); + + wallet.releaseReservedShieldedNotes('tx1'); + wallet.releaseReservedShieldedNotes('tx1'); // no-op, no throw + wallet.releaseReservedShieldedNotes('unknown'); // no-op, no throw + + expect(wallet.reservedShieldedNullifiers, {'n2'}); + + // Released notes are selectable again. + wallet.reserveShieldedNotes('tx3', ['n1']); + expect(wallet.reservedShieldedNullifiers, {'n1', 'n2'}); + }); + }); + + group('ensureShieldedNotesNotLocked (build pre-check)', () { + const notes = {'n1': 3000000, 'n2': 500000}; + + test('passes when nothing is reserved', () { + PivxWalletBase.ensureShieldedNotesNotLocked( + spendableNoteValuesByNullifier: notes, + reservedNullifiers: const {}, + amount: 3400000, + spendAll: false, + ); + }); + + test('fails with locked-funds error when reserved notes are the shortfall', + () { + expect( + () => PivxWalletBase.ensureShieldedNotesNotLocked( + spendableNoteValuesByNullifier: notes, + reservedNullifiers: const {'n1'}, + amount: 1000000, + spendAll: false, + ), + _lockedError, + ); + }); + + test('passes when unreserved notes still cover the amount', () { + PivxWalletBase.ensureShieldedNotesNotLocked( + spendableNoteValuesByNullifier: notes, + reservedNullifiers: const {'n2'}, + amount: 2500000, + spendAll: false, + ); + }); + + test('fails a spend-all while any spendable note is reserved', () { + expect( + () => PivxWalletBase.ensureShieldedNotesNotLocked( + spendableNoteValuesByNullifier: notes, + reservedNullifiers: const {'n2'}, + amount: 100, + spendAll: true, + ), + _lockedError, + ); + }); + + test( + 'stays silent when balance is insufficient regardless of reservations ' + 'so the standard insufficient-balance error surfaces', () { + PivxWalletBase.ensureShieldedNotesNotLocked( + spendableNoteValuesByNullifier: notes, + reservedNullifiers: const {'n1'}, + amount: 9000000, + spendAll: false, + ); + }); + }); + + group('broadcast failure releases the reservation', () { + test('failed broadcast frees the notes so a rebuild can use them', + () async { + final client = _FakeBroadcastElectrumClient(broadcastResponse: ''); + final wallet = testWallet(client); + final result = _transactionResult( + txId: 'a' * 64, + spentNullifiers: ['n1', 'n2'], + ); + + wallet.reserveShieldedNotes(result.txId, result.spentNullifiers); + final pending = PendingPivxShieldedTransaction( + result: result, + electrumClient: client, + amount: 2000000, + fee: result.fee, + onBroadcastFailure: () => + wallet.releaseReservedShieldedNotes(result.txId), + ); + + await expectLater( + pending.commit(), + throwsA(isA()), + ); + + expect(client.broadcastCalls, 1); + expect(wallet.reservedShieldedNullifiers, isEmpty); + + // A rebuilt transaction may now select the same notes again. + wallet.reserveShieldedNotes('b' * 64, ['n1', 'n2']); + expect(wallet.reservedShieldedNullifiers, {'n1', 'n2'}); + }); + + test( + 'post-broadcast bookkeeping failure is swallowed and still releases ' + 'the reservation', () async { + final txId = 'c' * 64; + final client = _FakeBroadcastElectrumClient(broadcastResponse: txId); + final wallet = testWallet(client); + final result = _transactionResult( + txId: txId, + spentNullifiers: ['n1'], + ); + + wallet.reserveShieldedNotes(result.txId, result.spentNullifiers); + var broadcastFailureCalled = false; + final pending = PendingPivxShieldedTransaction( + result: result, + electrumClient: client, + amount: 2000000, + fee: result.fee, + onBroadcastFailure: () { + broadcastFailureCalled = true; + wallet.releaseReservedShieldedNotes(result.txId); + }, + // Mirrors the wallet's real onCommit wiring: bookkeeping may throw, but + // the reservation is released in a finally. + onCommit: (_) async { + try { + throw Exception('post-broadcast bookkeeping'); + } finally { + wallet.releaseReservedShieldedNotes(result.txId); + } + }, + ); + + // The broadcast succeeded, so a bookkeeping failure afterward must not be + // surfaced as a broadcast failure (which would prompt a double send)... + await pending.commit(); + + expect(client.broadcastCalls, 1); + // ...the broadcast-failure hook must not fire (the notes really are + // spent)... + expect(broadcastFailureCalled, isFalse); + // ...and the reservation must not be left locked for the session. + expect(wallet.reservedShieldedNullifiers, isEmpty); + }); + }); + + group('successful commit keeps notes unavailable', () { + test('commit marks notes pending spent in storage and drops reservation', + () async { + final txId = 'd' * 64; + final client = _FakeBroadcastElectrumClient(broadcastResponse: txId); + final wallet = testWallet(client); + final storage = SaplingNoteStorage( + walletId: + 'reservation_test_${DateTime.now().millisecondsSinceEpoch}', + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage.load(); + await storage.addNote(_spendableNote(id: 'tx0:0', nullifier: 'n1')); + const chainHeight = + 100 + PivxShieldedConfirmationPolicy.spendConfirmations - 1; + expect( + storage.spendableNotesAt(chainHeight: chainHeight).length, + 1, + ); + + final result = _transactionResult(txId: txId, spentNullifiers: ['n1']); + wallet.reserveShieldedNotes(result.txId, result.spentNullifiers); + + var broadcastFailureCalled = false; + final pending = PendingPivxShieldedTransaction( + result: result, + electrumClient: client, + amount: 2000000, + fee: result.fee, + onBroadcastFailure: () => broadcastFailureCalled = true, + // Mirrors the wallet's real onCommit wiring: storage takes over the + // exclusion, then the in-memory reservation is released. + onCommit: (_) async { + await storage.markPendingSpentByNullifiers( + result.spentNullifiers, + result.txId, + ); + wallet.releaseReservedShieldedNotes(result.txId); + }, + ); + + await pending.commit(); + + expect(broadcastFailureCalled, isFalse); + expect(wallet.reservedShieldedNullifiers, isEmpty); + // Terminal pending-spent path unchanged: the note stays out of the + // spendable set even though the in-memory reservation is gone. + expect(storage.spendableNotesAt(chainHeight: chainHeight), isEmpty); + expect(storage.pendingSpentNotes.map((n) => n.nullifier), ['n1']); + }); + }); + + group('transparent fee rate', () { + test('uses PIVX fixed rates, not the zero electrum server rate', () { + final wallet = testWallet(_FakeBroadcastElectrumClient( + broadcastResponse: '', + )); + // Base feeRate reads _feeRates (0 for PIVX's server, no estimatefee); the + // override must return PIVX's fixed rates so a transparent send is not a + // rejected zero-fee tx. + expect(wallet.feeRate(PivxTransactionPriority.slow), 10000); + expect(wallet.feeRate(PivxTransactionPriority.medium), 20000); + expect(wallet.feeRate(PivxTransactionPriority.fast), 50000); + expect( + wallet.feeAmountForPriority(PivxTransactionPriority.slow, 1, 2), + greaterThan(0), + ); + }); + }); +} + +SaplingTransactionResult _transactionResult({ + required String txId, + required List spentNullifiers, +}) { + return SaplingTransactionResult( + rawTx: Uint8List.fromList(const [0x03, 0x00]), + txHex: '0300', + txId: txId, + fee: 1417000, + spentNullifiers: spentNullifiers, + ); +} + +StoredSaplingNote _spendableNote({ + required String id, + required String nullifier, +}) { + return StoredSaplingNote( + id: id, + value: 3000000, + height: 100, + txid: id.split(':').first, + outputIndex: 0, + treePosition: 0, + cmu: 'cm_$id', + nullifier: nullifier, + rseed: 'aa' * 32, + diversifier: 'bb' * 11, + pkD: 'cc' * 32, + ); +} + +PivxWallet _testWallet({ + required Box unspentCoinsInfo, + required electrum.ElectrumClient electrumClient, +}) { + const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + return PivxWallet( + mnemonic: mnemonic, + password: 'password', + walletInfo: WalletInfo.external( + id: 'pivx_reservation_test', + name: 'pivx_reservation_test', + type: WalletType.pivx, + isRecovery: false, + restoreHeight: 0, + date: DateTime.fromMillisecondsSinceEpoch(0), + dirPath: '', + path: '', + address: '', + ), + derivationInfo: DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/44'/119'/0'", + scriptType: 'p2pkh', + ), + unspentCoinsInfo: unspentCoinsInfo, + seedBytes: MnemonicBip39.toSeed(mnemonic), + encryptionFileUtils: _FakeEncryptionFileUtils(), + initialAddresses: [ + BitcoinAddressRecord( + generateP2PKHAddress( + hd: _testMainHd, + index: 0, + network: PivxNetwork.mainnet, + ), + index: 0, + isHidden: false, + type: P2pkhAddressType.p2pkh, + network: null, + ), + ], + initialBalance: ElectrumBalance( + confirmed: Money.fromInt(7000, CryptoCurrency.pivx), + unconfirmed: Money.fromInt(300, CryptoCurrency.pivx), + frozen: Money.fromInt(9, CryptoCurrency.pivx), + secondConfirmed: Money.fromInt(0, CryptoCurrency.pivx), + secondUnconfirmed: Money.fromInt(0, CryptoCurrency.pivx), + ), + electrumClient: electrumClient, + ); +} + +final _testAccountHd = Bip32Slip10Secp256k1.fromSeed(Uint8List(64)); +final _testMainHd = _testAccountHd.childKey(Bip32KeyIndex(0)); diff --git a/cw_pivx/test/sapling_ffi_memory_test.dart b/cw_pivx/test/sapling_ffi_memory_test.dart new file mode 100644 index 0000000000..51f9e8c088 --- /dev/null +++ b/cw_pivx/test/sapling_ffi_memory_test.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'dart:ffi'; + +import 'package:cw_pivx/src/sapling/sapling_ffi.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PIVX Sapling FFI memory hygiene', () { + test('zeros native Uint8 buffers before free', () { + final pointer = malloc(4); + try { + final bytes = pointer.asTypedList(4); + bytes.setAll(0, [1, 2, 3, 4]); + + zeroNativeUint8Buffer(pointer, bytes.length); + + expect(bytes, everyElement(0)); + } finally { + malloc.free(pointer); + } + }); + + test('zeros native UTF-8 strings before free', () { + const value = 'memo piñata'; + final pointer = value.toNativeUtf8(); + try { + final bytes = + pointer.cast().asTypedList(utf8.encode(value).length + 1); + expect(bytes.any((byte) => byte != 0), isTrue); + + zeroNativeUtf8String(pointer, value); + + expect(bytes, everyElement(0)); + } finally { + malloc.free(pointer); + } + }); + }); +} diff --git a/cw_pivx/test/sapling_note_storage_test.dart b/cw_pivx/test/sapling_note_storage_test.dart new file mode 100644 index 0000000000..5175e4d86c --- /dev/null +++ b/cw_pivx/test/sapling_note_storage_test.dart @@ -0,0 +1,1100 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:cw_pivx/src/sapling/sapling_note_storage.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +// Mock path provider for testing +class MockPathProviderPlatform extends Fake + with MockPlatformInterfaceMixin + implements PathProviderPlatform { + @override + Future getApplicationDocumentsPath() async { + return Directory.systemTemp.path; + } +} + +class FakeEncryptionFileUtils extends EncryptionFileUtils { + static const _prefix = 'encrypted:'; + + @override + Future write({ + required String path, + required String password, + required String data, + }) async { + await File(path) + .writeAsString('$_prefix${base64Encode(utf8.encode(data))}'); + } + + @override + Future read({ + required String path, + required String password, + }) async { + final encrypted = await File(path).readAsString(); + if (!encrypted.startsWith(_prefix)) { + throw const FormatException('Missing test encryption prefix'); + } + return utf8.decode(base64Decode(encrypted.substring(_prefix.length))); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + PathProviderPlatform.instance = MockPathProviderPlatform(); + }); + + group('SaplingNoteStorage Thread Safety', () { + late SaplingNoteStorage storage; + + setUp(() async { + storage = SaplingNoteStorage( + walletId: 'test_wallet_${DateTime.now().millisecondsSinceEpoch}', + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage.load(); + }); + + tearDown(() async { + // Clean up test files - storage will clean up on its own + // We don't need to manually delete as temp files will be cleared + }); + + test('concurrent addNote operations do not lose notes', () async { + // Add 100 notes concurrently + final futures = List.generate(100, (i) { + final note = StoredSaplingNote( + id: 'tx$i:0', + value: 1000 + i, + height: 1000 + i, + txid: 'txid_$i', + outputIndex: 0, + treePosition: i, + cmu: 'cmu_$i', + ); + return storage.addNote(note); + }); + + await Future.wait(futures); + + // Verify all notes were saved + expect(storage.notes.length, equals(100)); + + // Verify all values are present + final values = storage.notes.map((n) => n.value).toSet(); + expect(values.length, equals(100)); + for (int i = 0; i < 100; i++) { + expect(values.contains(1000 + i), true); + } + }); + + test('advances shielded receive index without moving backwards', () async { + expect(storage.nextDiversifierIndex, 1); + + await storage.advanceNextDiversifierIndexAtLeast(8); + expect(storage.nextDiversifierIndex, 8); + + await storage.advanceNextDiversifierIndexAtLeast(3); + expect(storage.nextDiversifierIndex, 8); + + expect(storage.getAndIncrementDiversifierIndex(), 8); + expect(storage.nextDiversifierIndex, 9); + }); + + test('persists generated shielded addresses and next receive index', + () async { + final walletId = + 'shielded_addresses_${DateTime.now().millisecondsSinceEpoch}'; + final storage1 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + await storage1.addAddress(StoredShieldedAddress( + diversifierIndex: 1, + address: 'ptestsapling1generated1', + label: 'first generated', + )); + await storage1.addAddress(StoredShieldedAddress( + diversifierIndex: 3, + address: 'ptestsapling1generated3', + label: 'third generated', + )); + + final storage2 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage2.load(); + + expect(storage2.addresses.map((address) => address.address), [ + 'ptestsapling1generated1', + 'ptestsapling1generated3', + ]); + expect(storage2.addresses.last.label, equals('third generated')); + expect(storage2.nextDiversifierIndex, equals(4)); + }); + + test('updating an existing shielded address preserves receive index', + () async { + await storage.addAddress(StoredShieldedAddress( + diversifierIndex: 5, + address: 'ptestsapling1generated5', + label: 'old label', + )); + expect(storage.nextDiversifierIndex, equals(6)); + + await storage.addAddress(StoredShieldedAddress( + diversifierIndex: 5, + address: 'ptestsapling1generated5', + label: 'new label', + )); + + expect(storage.addresses, hasLength(1)); + expect(storage.addresses.single.label, equals('new label')); + expect(storage.nextDiversifierIndex, equals(6)); + }); + + test('concurrent markSpentByNullifier operations are thread-safe', + () async { + // Add notes first + for (int i = 0; i < 50; i++) { + await storage.addNote(StoredSaplingNote( + id: 'tx$i:0', + value: 1000, + height: 1000 + i, + txid: 'txid_$i', + outputIndex: 0, + treePosition: i, + cmu: 'cmu_$i', + nullifier: 'nf_$i', + )); + } + + // Mark them all spent concurrently + final futures = List.generate(50, (i) { + return storage.markSpentByNullifier('nf_$i', 'spending_tx_$i'); + }); + + await Future.wait(futures); + + // Verify all marked as spent + final spentCount = storage.notes.where((n) => n.isSpent).length; + expect(spentCount, equals(50)); + }); + + test('pending spent nullifiers are reserved and excluded from balance', + () async { + await storage.addNote(StoredSaplingNote( + id: 'tx0:0', + value: 5000, + height: 1000, + txid: 'tx0', + outputIndex: 0, + treePosition: 0, + cmu: 'cmu_0', + nullifier: 'nf_0', + )); + await storage.addNote(StoredSaplingNote( + id: 'tx1:0', + value: 7000, + height: 1001, + txid: 'tx1', + outputIndex: 0, + treePosition: 1, + cmu: 'cmu_1', + nullifier: 'nf_1', + )); + + final reserved = await storage.markPendingSpentByNullifiers( + ['nf_0'], + 'pending_txid', + ); + + expect(reserved, equals(5000)); + expect(storage.balance, equals(7000)); + expect(storage.pendingOutgoingBalance, equals(5000)); + expect(storage.notes.first.isPendingSpend, isTrue); + + await storage.markSpentByNullifier('nf_0', 'mined_txid'); + + expect(storage.notes.first.isSpent, isTrue); + expect(storage.notes.first.isPendingSpend, isFalse); + expect(storage.notes.first.spendingTxid, equals('mined_txid')); + expect(storage.notes.first.pendingSpendingTxid, isNull); + expect(storage.pendingOutgoingBalance, equals(0)); + }); + + test('shielded balance separates pending, confirmed, and spendable notes', + () async { + await storage.addNote(StoredSaplingNote( + id: 'young_tx:0', + value: 5000, + height: 100, + txid: 'young_tx', + outputIndex: 0, + treePosition: 0, + cmu: 'cmu_young', + nullifier: 'nf_young', + rseed: 'rseed_young', + diversifier: 'diversifier_young', + pkD: 'pkd_young', + )); + await storage.addNote(StoredSaplingNote( + id: 'missing_spend_data_tx:0', + value: 7000, + height: 99, + txid: 'missing_spend_data_tx', + outputIndex: 0, + treePosition: 1, + cmu: 'cmu_missing', + )); + + expect( + storage.pendingReceivedBalanceAt( + chainHeight: 104, + minConfirmations: 6, + ), + equals(5000), + ); + expect( + storage.spendableBalanceAt( + chainHeight: 104, + minConfirmations: 6, + ), + equals(0), + ); + expect( + storage.spendableBalanceAt( + chainHeight: 105, + minConfirmations: 6, + ), + equals(5000), + ); + expect( + storage.confirmedBalanceAt( + chainHeight: 105, + minConfirmations: 6, + requireSpendingData: false, + ), + equals(12000), + ); + expect( + storage.confirmedBalanceAt( + chainHeight: 105, + minConfirmations: 6, + ), + equals(5000), + ); + }); + + test('shielded spend eligibility summary is count-only and maturity-aware', + () async { + await storage.addNote(StoredSaplingNote( + id: 'young_tx:0', + value: 5000, + height: 100, + txid: 'young_tx', + outputIndex: 0, + treePosition: 0, + cmu: 'cmu_young', + nullifier: 'nf_young', + rseed: 'rseed_young', + diversifier: 'diversifier_young', + pkD: 'pkd_young', + )); + await storage.addNote(StoredSaplingNote( + id: 'mature_tx:0', + value: 6000, + height: 99, + txid: 'mature_tx', + outputIndex: 0, + treePosition: 1, + cmu: 'cmu_mature', + nullifier: 'nf_mature', + rseed: 'rseed_mature', + diversifier: 'diversifier_mature', + pkD: 'pkd_mature', + )); + await storage.addNote(StoredSaplingNote( + id: 'missing_spend_data_tx:0', + value: 7000, + height: 99, + txid: 'missing_spend_data_tx', + outputIndex: 0, + treePosition: 2, + cmu: 'cmu_missing', + )); + await storage.addNote(StoredSaplingNote( + id: 'pending_spend_tx:0', + value: 8000, + height: 99, + txid: 'pending_spend_tx', + outputIndex: 0, + treePosition: 3, + cmu: 'cmu_pending', + nullifier: 'nf_pending', + rseed: 'rseed_pending', + diversifier: 'diversifier_pending', + pkD: 'pkd_pending', + isPendingSpend: true, + )); + await storage.addNote(StoredSaplingNote( + id: 'spent_tx:0', + value: 9000, + height: 99, + txid: 'spent_tx', + outputIndex: 0, + treePosition: 4, + cmu: 'cmu_spent', + nullifier: 'nf_spent', + rseed: 'rseed_spent', + diversifier: 'diversifier_spent', + pkD: 'pkd_spent', + isSpent: true, + )); + + final summary = storage.spendEligibilitySummaryAt( + chainHeight: 104, + minConfirmations: 6, + ); + + expect(summary.totalUnspent, equals(4)); + expect(summary.spendable, equals(1)); + expect(summary.pendingConfirmation, equals(1)); + expect(summary.pendingSpend, equals(1)); + expect(summary.missingSpendingData, equals(1)); + expect(summary.sanitizedLogLine, contains('min_confirmations=6')); + expect(summary.sanitizedLogLine, contains('spendable=1')); + expect(summary.sanitizedLogLine, isNot(contains('tx'))); + expect(summary.sanitizedLogLine, isNot(contains('nf_'))); + expect(summary.sanitizedLogLine, isNot(contains('cmu_'))); + }); + + test('concurrent balance calculations are consistent', () async { + // Add notes + for (int i = 0; i < 20; i++) { + await storage.addNote(StoredSaplingNote( + id: 'tx$i:0', + value: 1000, + height: 1000 + i, + txid: 'txid_$i', + outputIndex: 0, + treePosition: i, + cmu: 'cmu_$i', + )); + } + + // Read balance concurrently using thread-safe method + final futures = List.generate(100, (_) async { + return await storage.getBalanceSafe(); + }); + + final balances = await Future.wait(futures); + + // All balances should be the same + expect(balances.toSet().length, equals(1)); + expect(balances.first, equals(20000)); + }); + + test('concurrent addNote with duplicate IDs updates existing', () async { + // Add same note ID multiple times concurrently + final futures = List.generate(50, (i) { + final note = StoredSaplingNote( + id: 'same_tx:0', + value: 1000 + i, // Different values + height: 1000, + txid: 'same_tx', + outputIndex: 0, + treePosition: 0, + cmu: 'cmu', + ); + return storage.addNote(note); + }); + + await Future.wait(futures); + + // Should only have 1 note (duplicates updated) + expect(storage.notes.length, equals(1)); + expect(storage.notes.first.id, equals('same_tx:0')); + // Value will be from one of the concurrent updates + expect(storage.notes.first.value, greaterThanOrEqualTo(1000)); + expect(storage.notes.first.value, lessThan(1050)); + }); + + test('concurrent setLastSyncedHeight operations maintain consistency', + () async { + // Update height concurrently + final futures = List.generate(100, (i) { + return storage.setLastSyncedHeight(2700000 + i); + }); + + await Future.wait(futures); + + // Last synced height should be one of the values we set + expect(storage.lastSyncedHeight, greaterThanOrEqualTo(2700000)); + expect(storage.lastSyncedHeight, lessThan(2700100)); + }); + + test('nextTreePosition persists independently from owned notes', () async { + final walletId = + 'tree_position_test_${DateTime.now().millisecondsSinceEpoch}'; + final storage1 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + await storage1.setNextTreePosition(42); + expect(storage1.nextTreePosition, equals(42)); + + final storage2 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage2.load(); + + expect(storage2.notes, isEmpty); + expect(storage2.nextTreePosition, equals(42)); + expect(storage2.hasPersistedTreePosition, isTrue); + }); + + test('legacy note-derived tree position is not treated as persisted', + () async { + final walletId = + 'legacy_tree_position_${DateTime.now().millisecondsSinceEpoch}'; + final legacyFile = File( + '${Directory.systemTemp.path}/pivx_sapling_${walletId}_testnet.json'); + + if (await legacyFile.exists()) await legacyFile.delete(); + + await legacyFile.writeAsString(jsonEncode({ + 'lastSyncedHeight': 2700510, + 'nextDiversifierIndex': 1, + 'notes': [ + { + 'id': 'txid:0', + 'value': 1000, + 'height': 2700501, + 'txid': 'txid', + 'outputIndex': 0, + 'treePosition': 41, + 'cmu': 'cmu', + 'isSpent': false, + } + ], + 'addresses': >[], + })); + + final legacyStorage = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + + await legacyStorage.load(); + + expect(legacyStorage.nextTreePosition, equals(42)); + expect(legacyStorage.hasPersistedTreePosition, isFalse); + }); + + test('sync height and tree position persist atomically', () async { + final walletId = + 'complete_range_${DateTime.now().millisecondsSinceEpoch}'; + final storage1 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + await storage1.completeSyncRange( + lastSyncedHeight: 2700600, + nextTreePosition: 99, + treePositionIsTrusted: true, + ); + + final storage2 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage2.load(); + + expect(storage2.lastSyncedHeight, equals(2700600)); + expect(storage2.nextTreePosition, equals(99)); + expect(storage2.hasPersistedTreePosition, isTrue); + }); + + test('untrusted sync completion does not persist tree cursor', () async { + final walletId = + 'untrusted_complete_${DateTime.now().millisecondsSinceEpoch}'; + final storage1 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + await storage1.completeSyncRange( + lastSyncedHeight: 2700600, + nextTreePosition: 99, + treePositionIsTrusted: false, + ); + + final storage2 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage2.load(); + + expect(storage2.lastSyncedHeight, equals(2700600)); + expect(storage2.nextTreePosition, equals(0)); + expect(storage2.hasPersistedTreePosition, isFalse); + }); + + test('clear removes trusted tree cursor', () async { + final walletId = 'clear_cursor_${DateTime.now().millisecondsSinceEpoch}'; + final storage1 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + await storage1.setNextTreePosition(42); + await storage1.clear(); + + final storage2 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage2.load(); + + expect(storage2.lastSyncedHeight, equals(0)); + expect(storage2.nextTreePosition, equals(0)); + expect(storage2.hasPersistedTreePosition, isFalse); + }); + + test('sync completion persists scanned block hashes', () async { + final walletId = + 'scanned_hashes_${DateTime.now().millisecondsSinceEpoch}'; + final storage1 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + await storage1.completeSyncRange( + lastSyncedHeight: 2700502, + nextTreePosition: 0, + treePositionIsTrusted: false, + blockHashes: { + 2700500: 'hash_0', + 2700501: 'hash_1', + 2700502: 'hash_2', + }, + ); + + final storage2 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage2.load(); + + expect(storage2.scannedBlockHashes[2700500], equals('hash_0')); + expect(storage2.scannedBlockHashes[2700502], equals('hash_2')); + }); + + test('rewind removes stale notes and clears reorged spend markers', + () async { + final walletId = 'rewind_${DateTime.now().millisecondsSinceEpoch}'; + final storage1 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + await storage1.addNote(StoredSaplingNote( + id: 'kept_tx:0', + value: 5000, + height: 2700501, + txid: 'kept_tx', + outputIndex: 0, + treePosition: 0, + cmu: 'cmu_kept', + nullifier: 'nf_kept', + )); + await storage1.addNote(StoredSaplingNote( + id: 'removed_tx:0', + value: 7000, + height: 2700504, + txid: 'removed_tx', + outputIndex: 0, + treePosition: 1, + cmu: 'cmu_removed', + )); + await storage1.markSpentByNullifier( + 'nf_kept', + 'spending_tx', + spendingHeight: 2700504, + ); + await storage1.completeSyncRange( + lastSyncedHeight: 2700505, + nextTreePosition: 12, + treePositionIsTrusted: true, + blockHashes: { + 2700501: 'hash_1', + 2700504: 'hash_4', + 2700505: 'hash_5', + }, + ); + + await storage1.rewindToHeight(2700502); + + expect(storage1.lastSyncedHeight, equals(2700502)); + expect(storage1.notes.map((note) => note.id), equals(['kept_tx:0'])); + expect(storage1.notes.single.isSpent, isFalse); + expect(storage1.notes.single.spendingTxid, isNull); + expect(storage1.nextTreePosition, equals(0)); + expect(storage1.hasPersistedTreePosition, isFalse); + expect(storage1.scannedBlockHashes.containsKey(2700504), isFalse); + }); + + test('unencrypted storage is rejected unless explicitly allowed', () async { + final protectedStorage = SaplingNoteStorage( + walletId: 'encrypted_required_${DateTime.now().millisecondsSinceEpoch}', + isTestnet: true, + ); + + expect(protectedStorage.load(), throwsA(isA())); + }); + + test('legacy plaintext sidecar migrates to encrypted storage', () async { + final walletId = + 'legacy_migration_${DateTime.now().millisecondsSinceEpoch}'; + final legacyFile = File( + '${Directory.systemTemp.path}/pivx_sapling_${walletId}_testnet.json'); + final encryptedFile = File( + '${Directory.systemTemp.path}/pivx_sapling_${walletId}_testnet.json.enc'); + + if (await legacyFile.exists()) await legacyFile.delete(); + if (await encryptedFile.exists()) await encryptedFile.delete(); + + await legacyFile.writeAsString(jsonEncode({ + 'lastSyncedHeight': 2700501, + 'nextTreePosition': 77, + 'nextDiversifierIndex': 3, + 'notes': >[], + 'addresses': >[], + })); + + final encryptedStorage = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + encryptionFileUtils: FakeEncryptionFileUtils(), + password: 'test-password', + ); + + await encryptedStorage.load(); + + expect(encryptedStorage.lastSyncedHeight, equals(2700501)); + expect(encryptedStorage.nextTreePosition, equals(77)); + expect(await legacyFile.exists(), isFalse); + expect(await encryptedFile.exists(), isTrue); + expect(await encryptedFile.readAsString(), + isNot(contains('lastSyncedHeight'))); + + final reloadedStorage = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + encryptionFileUtils: FakeEncryptionFileUtils(), + password: 'test-password', + ); + + await reloadedStorage.load(); + expect(reloadedStorage.lastSyncedHeight, equals(2700501)); + expect(reloadedStorage.nextTreePosition, equals(77)); + expect(reloadedStorage.hasPersistedTreePosition, isTrue); + }); + + test('legacy migration does not trust inferred tree cursor', () async { + final walletId = + 'legacy_untrusted_cursor_${DateTime.now().millisecondsSinceEpoch}'; + final legacyFile = File( + '${Directory.systemTemp.path}/pivx_sapling_${walletId}_testnet.json'); + final encryptedFile = File( + '${Directory.systemTemp.path}/pivx_sapling_${walletId}_testnet.json.enc'); + + if (await legacyFile.exists()) await legacyFile.delete(); + if (await encryptedFile.exists()) await encryptedFile.delete(); + + await legacyFile.writeAsString(jsonEncode({ + 'lastSyncedHeight': 2700501, + 'nextDiversifierIndex': 1, + 'notes': [ + { + 'id': 'txid:0', + 'value': 1000, + 'height': 2700501, + 'txid': 'txid', + 'outputIndex': 0, + 'treePosition': 12, + 'cmu': 'cmu', + 'isSpent': false, + } + ], + 'addresses': >[], + })); + + final encryptedStorage = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + encryptionFileUtils: FakeEncryptionFileUtils(), + password: 'test-password', + ); + await encryptedStorage.load(); + + expect(encryptedStorage.nextTreePosition, equals(13)); + expect(encryptedStorage.hasPersistedTreePosition, isFalse); + + final reloadedStorage = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + encryptionFileUtils: FakeEncryptionFileUtils(), + password: 'test-password', + ); + await reloadedStorage.load(); + + expect(reloadedStorage.nextTreePosition, equals(13)); + expect(reloadedStorage.hasPersistedTreePosition, isFalse); + }); + + test('mixed concurrent operations (add, mark spent, read)', () async { + // Add initial notes + for (int i = 0; i < 20; i++) { + await storage.addNote(StoredSaplingNote( + id: 'tx$i:0', + value: 1000, + height: 1000 + i, + txid: 'txid_$i', + outputIndex: 0, + treePosition: i, + cmu: 'cmu_$i', + nullifier: 'nf_$i', + )); + } + + final futures = []; + + // Add more notes concurrently + for (int i = 20; i < 40; i++) { + futures.add(storage.addNote(StoredSaplingNote( + id: 'tx$i:0', + value: 1000, + height: 1000 + i, + txid: 'txid_$i', + outputIndex: 0, + treePosition: i, + cmu: 'cmu_$i', + nullifier: 'nf_$i', + ))); + } + + // Mark some spent concurrently + for (int i = 0; i < 10; i++) { + futures.add(storage.markSpentByNullifier('nf_$i', 'spending_tx')); + } + + // Read balance concurrently + for (int i = 0; i < 20; i++) { + futures.add(storage.getBalanceSafe()); + } + + await Future.wait(futures); + + // Verify final state + expect(storage.notes.length, equals(40)); + final spentCount = storage.notes.where((n) => n.isSpent).length; + expect(spentCount, equals(10)); + + // Balance should be 30 unspent notes * 1000 + final finalBalance = await storage.getBalanceSafe(); + expect(finalBalance, equals(30000)); + }); + + test('persistence survives concurrent writes', () async { + final storage1 = SaplingNoteStorage( + walletId: 'persist_test', + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + // Add notes concurrently + final futures = List.generate(50, (i) { + final note = StoredSaplingNote( + id: 'tx$i:0', + value: 1000, + height: 1000 + i, + txid: 'txid_$i', + outputIndex: 0, + treePosition: i, + cmu: 'cmu_$i', + ); + return storage1.addNote(note); + }); + + await Future.wait(futures); + + // Load in new instance + final storage2 = SaplingNoteStorage( + walletId: 'persist_test', + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage2.load(); + + // Verify all notes persisted + expect(storage2.notes.length, equals(50)); + final balance = await storage2.getBalanceSafe(); + expect(balance, equals(50000)); + + // Cleanup happens automatically with temp directory + }); + + test('concurrent clear and addNote persist one consistent snapshot', + () async { + final walletId = 'clear_race_${DateTime.now().millisecondsSinceEpoch}'; + final racingStorage = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await racingStorage.load(); + + for (var i = 0; i < 25; i++) { + // Seed non-reset state so a persisted file mixing pre-clear sync + // metadata with post-clear notes (or vice versa) is detectable. + await racingStorage.completeSyncRange( + lastSyncedHeight: 2700000 + i, + nextTreePosition: 500 + i, + treePositionIsTrusted: true, + blockHashes: {2700000 + i: 'hash_$i'}, + ); + + final note = StoredSaplingNote( + id: 'race$i:0', + value: 1000, + height: 2600000 + i, + txid: 'race$i', + outputIndex: 0, + treePosition: i, + cmu: 'cmu_race_$i', + ); + + // Race clear() against addNote(), alternating start order. + final ops = i.isEven + ? [racingStorage.clear(), racingStorage.addNote(note)] + : [racingStorage.addNote(note), racingStorage.clear()]; + await Future.wait(ops); + + // Reload from disk: the persisted state must equal one of the two + // serial outcomes (clear-then-add or add-then-clear), never a mix of + // old sync metadata with cleared notes or vice versa. + final reloaded = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await reloaded.load(); + + expect(reloaded.lastSyncedHeight, equals(0), + reason: 'clear() ran, so sync height must be reset (iteration $i)'); + // With no persisted cursor, load() falls back to the legacy + // max(note.treePosition)+1 hint, so clear-then-add yields i + 1. + expect(reloaded.nextTreePosition, + equals(reloaded.notes.isEmpty ? 0 : i + 1), + reason: 'clear() ran, so no trusted cursor may survive ' + '(iteration $i)'); + expect(reloaded.hasPersistedTreePosition, isFalse, + reason: 'clear() ran, so cursor trust must be reset (iteration $i)'); + expect(reloaded.scannedBlockHashes, isEmpty, + reason: 'clear() ran, so block hashes must be reset (iteration $i)'); + final noteIds = reloaded.notes.map((n) => n.id).toList(); + expect( + noteIds.isEmpty || + (noteIds.length == 1 && noteIds.single == 'race$i:0'), + isTrue, + reason: 'notes must be empty (add-then-clear) or exactly the added ' + 'note (clear-then-add), got $noteIds (iteration $i)', + ); + } + }); + + test('unexpected server-reported spend is quarantined, not terminal', + () async { + final walletId = 'quarantine_${DateTime.now().millisecondsSinceEpoch}'; + final storage1 = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await storage1.load(); + + StoredSaplingNote buildNote() => StoredSaplingNote( + id: 'victim_tx:0', + value: 5000, + height: 100, + txid: 'victim_tx', + outputIndex: 0, + treePosition: 0, + cmu: 'cmu_victim', + nullifier: 'nf_victim', + rseed: 'rseed_victim', + diversifier: 'diversifier_victim', + pkD: 'pkd_victim', + ); + + await storage1.addNote(buildNote()); + + // No pending/outgoing state: a server-reported spend must quarantine. + final handled = await storage1.recordObservedSpendByNullifier( + 'nf_victim', + 'evil_tx', + spendingHeight: 105, + ); + + expect(handled, isTrue); + final note = storage1.notes.single; + expect(note.isSpent, isFalse); + expect(note.isProvisionallySpent, isTrue); + expect(note.spendingTxid, equals('evil_tx')); + expect(storage1.quarantinedNullifiers, equals(['nf_victim'])); + + // Excluded from every spendable-balance surface. + expect(storage1.balance, equals(0)); + expect(storage1.spendableBalance, equals(0)); + expect( + storage1.spendableBalanceAt(chainHeight: 200, minConfirmations: 6), + equals(0), + ); + expect(storage1.unspentNotes, isEmpty); + + // Quarantine state persists across reload. + final reloaded = SaplingNoteStorage( + walletId: walletId, + isTestnet: true, + allowUnencryptedStorage: true, + ); + await reloaded.load(); + expect(reloaded.notes.single.isProvisionallySpent, isTrue); + expect(reloaded.notes.single.isSpent, isFalse); + expect(reloaded.quarantinedNullifiers, equals(['nf_victim'])); + + // Reversible by reorg rewind past the claimed spending height. + await storage1.rewindToHeight(102); + expect(storage1.notes.single.isProvisionallySpent, isFalse); + expect(storage1.quarantinedNullifiers, isEmpty); + expect(storage1.balance, equals(5000)); + + // Re-quarantine, then verify the clear()/rescan path resets it. + await storage1.recordObservedSpendByNullifier( + 'nf_victim', + 'evil_tx', + spendingHeight: 105, + ); + expect(storage1.quarantinedNullifiers, equals(['nf_victim'])); + + await storage1.clear(); + expect(storage1.quarantinedNullifiers, isEmpty); + + // Rescan rediscovers the note fresh and spendable. + await storage1.addNote(buildNote()); + expect(storage1.notes.single.isProvisionallySpent, isFalse); + expect(storage1.balance, equals(5000)); + expect(storage1.quarantinedNullifiers, isEmpty); + }); + + test('expected spend matching pending outgoing stays terminal', () async { + await storage.addNote(StoredSaplingNote( + id: 'mine_tx:0', + value: 5000, + height: 100, + txid: 'mine_tx', + outputIndex: 0, + treePosition: 0, + cmu: 'cmu_mine', + nullifier: 'nf_mine', + )); + + await storage.markPendingSpentByNullifiers(['nf_mine'], 'my_broadcast'); + + final handled = await storage.recordObservedSpendByNullifier( + 'nf_mine', + 'my_broadcast', + spendingHeight: 110, + ); + + expect(handled, isTrue); + final note = storage.notes.single; + expect(note.isSpent, isTrue); + expect(note.isProvisionallySpent, isFalse); + expect(note.isPendingSpend, isFalse); + expect(note.spendingTxid, equals('my_broadcast')); + expect(note.spendingHeight, equals(110)); + expect(note.pendingSpendingTxid, isNull); + expect(storage.quarantinedNullifiers, isEmpty); + expect(storage.pendingOutgoingBalance, equals(0)); + }); + + test('stress test with very high concurrency', () async { + // 1000 concurrent operations + final futures = []; + + for (int i = 0; i < 1000; i++) { + if (i % 2 == 0) { + // Add note + futures.add(storage.addNote(StoredSaplingNote( + id: 'tx$i:0', + value: i, + height: 1000 + i, + txid: 'txid_$i', + outputIndex: 0, + treePosition: i, + cmu: 'cmu_$i', + nullifier: i % 4 == 0 ? 'nf_$i' : null, + ))); + } else { + // Read balance using thread-safe method + futures.add(storage.getBalanceSafe()); + } + } + + await Future.wait(futures); + + // Should have 500 notes (half were adds) + expect(storage.notes.length, equals(500)); + + // Verify no corruption + final ids = storage.notes.map((n) => n.id).toSet(); + expect(ids.length, equals(500)); // All unique + }); + }); +} diff --git a/integration_test/components/common_test_flows.dart b/integration_test/components/common_test_flows.dart index 888c2ce03b..b2c8a00b03 100644 --- a/integration_test/components/common_test_flows.dart +++ b/integration_test/components/common_test_flows.dart @@ -371,6 +371,8 @@ class CommonTestFlows { return secrets.dogeTestWalletSeeds; case WalletType.zcash: return secrets.zcashTestWalletSeeds; + case WalletType.pivx: + return secrets.dogeTestWalletSeeds; // TODO: Add PIVX test wallet seeds case WalletType.none: case WalletType.haven: case WalletType.banano: diff --git a/integration_test/pivx_proving_params_download_test.dart b/integration_test/pivx_proving_params_download_test.dart new file mode 100644 index 0000000000..d8f25c5f24 --- /dev/null +++ b/integration_test/pivx_proving_params_download_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/utils/tor/disabled.dart'; +import 'package:cw_pivx/src/sapling/sapling_constants.dart'; +import 'package:cw_pivx/src/sapling/sapling_factories.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path_provider/path_provider.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'downloads and verifies PIVX Sapling proving params in app storage', + (_) async { + CakeTor.instance = CakeTorDisabled(); + + final appDir = await getApplicationDocumentsDirectory(); + final paramsDir = Directory('${appDir.path}/pivx_sapling_params_it'); + if (await paramsDir.exists()) { + await paramsDir.delete(recursive: true); + } + + final progress = []; + await SaplingTransactionBuilderWrapper.downloadProvingParamsToPath( + path: paramsDir.path, + onProgress: progress.add, + ); + + final spendFile = + File('${paramsDir.path}/${SaplingParams.spendParamsFileName}'); + final outputFile = + File('${paramsDir.path}/${SaplingParams.outputParamsFileName}'); + + expect(await spendFile.length(), SaplingParams.spendParamsSize); + expect(await outputFile.length(), SaplingParams.outputParamsSize); + expect(await File('${spendFile.path}.download').exists(), isFalse); + expect(await File('${outputFile.path}.download').exists(), isFalse); + expect(progress, isNotEmpty); + expect(progress.last, 1.0); + }, + timeout: const Timeout(Duration(minutes: 5)), + ); +} diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 4e4789e578..13d036ffdc 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -216,6 +216,17 @@ class CWBitcoin extends Bitcoin { return estimatedTx.amount; } + if (wallet.type == WalletType.pivx) { + final pivxAddr = + sk.getPublic().toP2pkhAddress(); + final estimatedTx = await electrumWallet.estimateSendAllTx( + [BitcoinOutput(address: pivxAddr, value: BigInt.zero)], + getFeeRate(wallet, priority as BitcoinTransactionPriority), + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + return estimatedTx.amount; + } + final p2shAddr = sk.getPublic().toP2pkhAddress(); final estimatedTx = await electrumWallet.estimateSendAllTx( [BitcoinOutput(address: p2shAddr, value: BigInt.zero)], @@ -262,6 +273,13 @@ class CWBitcoin extends Bitcoin { return element.bitcoinAddressRecord.type == SegwitAddresType.mweb; case UnspentCoinType.nonMweb: return element.bitcoinAddressRecord.type != SegwitAddresType.mweb; + case UnspentCoinType.sapling: + // PIVX shielded notes are tracked separately, not as UTXOs + // Return empty list - shielded balance is handled differently + return false; + case UnspentCoinType.transparent: + // For PIVX, transparent means all non-shielded UTXOs + return true; case UnspentCoinType.lightning: case UnspentCoinType.any: return true; diff --git a/lib/buy/robinhood/robinhood_buy_provider.dart b/lib/buy/robinhood/robinhood_buy_provider.dart index 651dc6b84e..e95269c72d 100644 --- a/lib/buy/robinhood/robinhood_buy_provider.dart +++ b/lib/buy/robinhood/robinhood_buy_provider.dart @@ -125,6 +125,7 @@ class RobinhoodBuyProvider extends BuyProvider { case WalletType.zano: case WalletType.zcash: case WalletType.decred: + case WalletType.pivx: throw Exception("Wallet Type ${wallet.type.name} is not available for Robinhood"); } } diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 1c4c6d3728..b283689855 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -193,7 +193,13 @@ class AddressValidator extends TextValidator { case CryptoCurrency.kmd: pattern = 'R[0-9a-zA-Z]{33}'; case CryptoCurrency.pivx: - pattern = 'D([1-9a-km-zA-HJ-NP-Z]){33}'; + // PIVX address formats: + // - D... : Standard P2PKH addresses (34 chars, version byte 30) + // - EXM... : Exchange addresses (37 chars, version bytes [0x01, 0xb9, 0xa2]) + // - S... : Staking addresses (version byte 63) + // - ps1... : Sapling shielded addresses (bech32, mainnet, ~75-80 chars after prefix) + // - ptestsapling1... : Sapling shielded addresses (bech32, testnet) + pattern = '(D([1-9a-km-zA-HJ-NP-Z]){33}|EXM([1-9a-km-zA-HJ-NP-Z]){33}|ps1[a-z0-9]{70,}|ptestsapling1[a-z0-9]{70,})'; case CryptoCurrency.btcln: pattern = r'(lightning:)?(lnbc|lntb|lnbs|lnbcrt|lnurl|LNBC|LNTB|LNBS|LNBCRT|LNURL)[a-zA-Z0-9]+'; @@ -325,9 +331,10 @@ class AddressValidator extends TextValidator { case CryptoCurrency.zec: return null; case CryptoCurrency.kmd: - case CryptoCurrency.pivx: case CryptoCurrency.rvn: return [34]; + case CryptoCurrency.pivx: + return null; // Variable length: 34 for D, 37 for EXM, ~78-80 for ps1 shielded case CryptoCurrency.dcr: return [35]; case CryptoCurrency.stx: diff --git a/lib/core/backup_service.dart b/lib/core/backup_service.dart index e85622af53..f0b8f1d336 100644 --- a/lib/core/backup_service.dart +++ b/lib/core/backup_service.dart @@ -50,8 +50,13 @@ class $BackupService { final decryptedData = await _decryptV1(data, password, nonce); final zip = ZipDecoder().decodeBytes(decryptedData); + outer: for (var file in zip.files) { final filename = file.name; + if (shouldIgnoreBackupPath(filename)) { + printV("ignoring backup file: $filename"); + continue outer; + } if (file.isFile) { final content = file.content as List; @@ -78,6 +83,22 @@ class $BackupService { ".lock", ]; + static bool shouldIgnoreBackupPath(String filename) { + final normalized = filename.replaceAll('\\', '/'); + + for (var ignore in ignoreFiles) { + if (normalized.endsWith(ignore) && !normalized.contains("wallets/")) { + return true; + } + } + + final basename = normalized.split('/').last; + return basename == 'pivx_sapling_params' || + normalized.contains('/pivx_sapling_params/') || + (basename.startsWith('sapling-') && basename.endsWith('.params.download')) || + (basename.startsWith('pivx_sapling_') && basename.endsWith('.json')); + } + Future importBackupV2(Uint8List data, String password) async { final appDir = await getAppDir(); final decryptedData = await decryptV2(data, password); @@ -86,11 +107,9 @@ class $BackupService { outer: for (var file in zip.files) { final filename = file.name; - for (var ignore in ignoreFiles) { - if (filename.endsWith(ignore) && !filename.contains("wallets/")) { - printV("ignoring backup file: $filename"); - continue outer; - } + if (shouldIgnoreBackupPath(filename)) { + printV("ignoring backup file: $filename"); + continue outer; } printV("restoring: $filename"); if (file.isFile) { @@ -283,7 +302,6 @@ class $BackupService { Future exportKeychainDumpV2(String password, {String keychainSalt = secrets.backupKeychainSalt}) async { - final key = generateStoreKeyFor(key: SecretStoreKey.pinCodePassword); final wallets = await Future.wait((await WalletInfo.getAll()).map((walletInfo) async { try { return { diff --git a/lib/core/node_switching_service.dart b/lib/core/node_switching_service.dart index d7908eebef..5fffbebb83 100644 --- a/lib/core/node_switching_service.dart +++ b/lib/core/node_switching_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cw_core/node.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/utils/feature_flag.dart'; @@ -40,6 +41,12 @@ class NodeSwitchingService { final Map> _usedNodeKeys = {}; + // A node either runs the v1 Sapling contract or it doesn't; that never changes + // mid-session. Cache per node so repeated switches don't reconnect and reprobe + // (a second socket + Sapling RPCs) every time. Only determinate probe results + // are cached; a transient failure is not, so a flaky node can be retried. + final Map _pivxSaplingSupport = {}; + void startHealthCheckTimer() { _healthCheckTimer?.cancel(); _healthCheckTimer = Timer.periodic( @@ -117,7 +124,7 @@ class NodeSwitchingService { ) async { for (final node in nodes) { if (!_usedNodeKeys[walletType]!.contains(node.id)) { - final isActive = await node.requestNode(); + final isActive = await _isNodeUsableForWallet(node, walletType); if (isActive) { return node; } else { @@ -129,6 +136,43 @@ class NodeSwitchingService { return null; } + Future _isNodeUsableForWallet(Node node, WalletType walletType) async { + final isActive = await node.requestNode(); + if (!isActive) { + return false; + } + + if (walletType != WalletType.pivx) { + return true; + } + + return _pivxNodeSupportsSapling(node); + } + + Future _pivxNodeSupportsSapling(Node node) async { + final cacheKey = node.uri.toString(); + final cached = _pivxSaplingSupport[cacheKey]; + if (cached != null) return cached; + + final proxy = pivx; + if (proxy == null) return false; + + try { + // The probe throws on a transient connection failure and returns a + // determinate bool when the node answers, so only determinate results are + // cached; a flaky node can be retried. + final supported = await proxy.checkNodeSupportsSapling( + uri: node.uri, + useSSL: node.useSSL, + isTestnet: appStore.wallet?.isTestnet ?? false, + ); + _pivxSaplingSupport[cacheKey] = supported; + return supported; + } catch (_) { + return false; + } + } + /// Switch to the next available trusted node Future _switchToNextTrustedNode() async { _isSwitching = true; diff --git a/lib/core/seed_validator.dart b/lib/core/seed_validator.dart index efe0f3655f..42d0252893 100644 --- a/lib/core/seed_validator.dart +++ b/lib/core/seed_validator.dart @@ -30,6 +30,8 @@ class SeedValidator extends Validator { return getBitcoinWordList(language); case WalletType.dogecoin: return getBitcoinWordList(language); + case WalletType.pivx: + return getBitcoinWordList(language); case WalletType.monero: return monero!.getMoneroWordList(language); case WalletType.ethereum: diff --git a/lib/core/wallet_creation_service.dart b/lib/core/wallet_creation_service.dart index 3700a1c845..74e098186f 100644 --- a/lib/core/wallet_creation_service.dart +++ b/lib/core/wallet_creation_service.dart @@ -84,6 +84,7 @@ class WalletCreationService { case WalletType.solana: case WalletType.tron: case WalletType.dogecoin: + case WalletType.pivx: case WalletType.nano: case WalletType.zcash: case WalletType.zano: diff --git a/lib/di.dart b/lib/di.dart index 1607091717..dcf337402a 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -283,6 +283,7 @@ import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_details_view_ import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart'; import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_address_list/address_edit_or_create_arguments.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; @@ -325,6 +326,7 @@ import 'buy/meld/meld_buy_provider.dart'; import 'dogecoin/dogecoin.dart'; import 'new-ui/viewmodels/card_customizer/card_customizer_bloc.dart'; import 'new-ui/widgets/addresses_page/address_info.dart'; +import 'pivx/pivx.dart'; import 'src/screens/buy/buy_sell_page.dart'; final getIt = GetIt.instance; @@ -874,14 +876,30 @@ Future setup({ lightningMode: param1 ?? false, initialCurrency: param2)); - getIt.registerFactoryParam( - (WalletAddressListItem? item, _) => - WalletAddressEditOrCreateViewModel(wallet: getIt.get().wallet!, item: item)); + getIt.registerFactoryParam( + (WalletAddressListItem? item, bool? isShielded) => + WalletAddressEditOrCreateViewModel( + wallet: getIt.get().wallet!, + item: item, + isShielded: isShielded ?? false, + )); - getIt.registerFactoryParam((dynamic item, _) => - AddressEditOrCreatePage( - addressEditOrCreateViewModel: - getIt.get(param1: item))); + getIt.registerFactoryParam((dynamic args, _) { + // Parse arguments - can be WalletAddressListItem (for edit), AddressEditOrCreateArguments, or null + WalletAddressListItem? item; + bool isShielded = false; + + if (args is AddressEditOrCreateArguments) { + item = args.item; + isShielded = args.isShielded; + } else if (args is WalletAddressListItem) { + item = args; + } + + return AddressEditOrCreatePage( + addressEditOrCreateViewModel: + getIt.get(param1: item, param2: isShielded)); + }); getIt.registerFactoryParam((dynamic item, _) => AddressLabelInputPopup( @@ -1348,6 +1366,9 @@ Future setup({ case WalletType.dogecoin: return dogecoin!.createDogeCoinWalletService( _unspentCoinsInfoSource, SettingsStoreBase.walletPasswordDirectInput); + case WalletType.pivx: + return pivx!.createPivxWalletService( + _unspentCoinsInfoSource, SettingsStoreBase.walletPasswordDirectInput); case WalletType.nano: case WalletType.banano: return nano!.createNanoWalletService(SettingsStoreBase.walletPasswordDirectInput); diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index 6a304e946a..ad5a8f54f7 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -51,6 +51,7 @@ const zanoDefaultNodeUri = '37.27.100.59:10500'; const moneroWorldNodeUri = '.moneroworld.com'; const decredDefaultUri = "default-spv-nodes"; const dogecoinDefaultNodeUri = 'dogecoin.stackwallet.com:50022'; +const pivxDefaultNodeUri = 'electrum02.chainster.org:50002'; const baseDefaultNodeUri = 'base-rpc.publicnode.com'; const arbitrumDefaultNodeUri = 'arbitrum-one-rpc.publicnode.com'; const bscDefaultNodeUri = 'bsc-dataseed.bnbchain.org'; @@ -648,6 +649,14 @@ Future defaultSettingsMigration( enabled: false, ); break; + case 72: + await addWalletNodeList(type: WalletType.pivx); + await _changeDefaultNode( + sharedPreferences: sharedPreferences, + type: WalletType.pivx, + currentNodePreferenceKey: PreferencesKey.currentPivxNodeIdKey, + ); + break; default: break; } diff --git a/lib/entities/node_check.dart b/lib/entities/node_check.dart index e66fc6dcee..3ffa8b04db 100644 --- a/lib/entities/node_check.dart +++ b/lib/entities/node_check.dart @@ -20,6 +20,7 @@ const Map nodePreferenceKeys = { WalletType.decred: PreferencesKey.currentDecredNodeIdKey, WalletType.bitcoinCash: PreferencesKey.currentBitcoinCashNodeIdKey, WalletType.dogecoin: PreferencesKey.currentDogecoinNodeIdKey, + WalletType.pivx: PreferencesKey.currentPivxNodeIdKey, WalletType.solana: PreferencesKey.currentSolanaNodeIdKey, WalletType.tron: PreferencesKey.currentTronNodeIdKey, WalletType.wownero: PreferencesKey.currentWowneroNodeIdKey, diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index d35c010f55..7758c119e4 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -15,6 +15,7 @@ class PreferencesKey { static const currentNanoPowNodeIdKey = 'current_node_id_nano_pow'; static const currentDecredNodeIdKey = 'current_node_id_decred'; static const currentDogecoinNodeIdKey = 'current_node_id_doge'; + static const currentPivxNodeIdKey = 'current_node_id_pivx'; static const currentBananoNodeIdKey = 'current_node_id_banano'; static const currentBananoPowNodeIdKey = 'current_node_id_banano_pow'; static const currentFiatCurrencyKey = 'current_fiat_currency'; @@ -55,6 +56,7 @@ class PreferencesKey { static const bitcoinTransactionPriority = 'current_fee_priority_bitcoin'; static const havenTransactionPriority = 'current_fee_priority_haven'; static const litecoinTransactionPriority = 'current_fee_priority_litecoin'; + static const pivxTransactionPriority = 'current_fee_priority_pivx'; static const ethereumTransactionPriority = 'current_fee_priority_ethereum'; static const polygonTransactionPriority = 'current_fee_priority_polygon'; static const baseTransactionPriority = 'current_fee_priority_base'; diff --git a/lib/entities/priority_for_wallet_type.dart b/lib/entities/priority_for_wallet_type.dart index f41c3b34b2..0492af82ba 100644 --- a/lib/entities/priority_for_wallet_type.dart +++ b/lib/entities/priority_for_wallet_type.dart @@ -3,6 +3,7 @@ import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; import 'package:cake_wallet/dogecoin/dogecoin.dart'; import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/monero/monero.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cake_wallet/wownero/wownero.dart'; import 'package:cake_wallet/zano/zano.dart'; import 'package:cake_wallet/decred/decred.dart'; @@ -29,6 +30,8 @@ List priorityForWalletType(WalletType type) { return bitcoinCash!.getTransactionPriorities(); case WalletType.dogecoin: return dogecoin!.getTransactionPriorities(); + case WalletType.pivx: + return pivx!.getTransactionPriorities(); case WalletType.arbitrum: case WalletType.nano: case WalletType.banano: diff --git a/lib/main.dart b/lib/main.dart index 53268a3049..fb9c3665b9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -309,7 +309,7 @@ Future initializeAppConfigs({bool loadWallet = true}) async { payjoinSessionSource: payjoinSessionSource, anonpayInvoiceInfo: anonpayInvoiceInfo, havenSeedStore: havenSeedStore, - initialMigrationVersion: 71, + initialMigrationVersion: 72, ); } diff --git a/lib/new-ui/pages/receive_page.dart b/lib/new-ui/pages/receive_page.dart index 2948f9a932..7f81338c23 100644 --- a/lib/new-ui/pages/receive_page.dart +++ b/lib/new-ui/pages/receive_page.dart @@ -27,6 +27,7 @@ import "package:cake_wallet/view_model/dashboard/receive_option_view_model.dart" import "package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart"; import "package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart"; import "package:cake_wallet/zcash/zcash.dart"; +import "package:cake_wallet/pivx/pivx.dart"; import "package:cw_core/crypto_currency.dart"; import "package:cw_core/payment_uris.dart"; import "package:cw_core/receive_page_option.dart"; @@ -115,6 +116,11 @@ class _NewReceivePageState extends State { widget.addressListViewModel.setAddressType(zcash!.getOptionToType(option)); return; } + if (widget.dashboardViewModel.type == WalletType.pivx && + pivx!.isPivxReceivePageOption(option)) { + widget.addressListViewModel.setAddressType(pivx!.getOptionToType(option)); + return; + } switch (option) { case ReceivePageOption.anonPayInvoice: @@ -371,12 +377,18 @@ class _NewReceivePageState extends State { ); } - /// Zcash is the only wallet type that also offers static address types, so - /// the rotation notice must not be shown for it unless the disposable - /// transparent type is the one currently selected. - bool get _selectedAddressRotates => - widget.addressListViewModel.type != WalletType.zcash || - zcash!.isRotatingAddressOption(widget.receiveOptionViewModel.selectedReceiveOption); + /// Zcash and PIVX both offer static address types, so the rotation notice + /// shows only when a rotating type is selected: for Zcash the disposable + /// transparent option, for PIVX any transparent address (shielded ps1 is + /// static). + bool get _selectedAddressRotates { + final vm = widget.addressListViewModel; + if (vm.type == WalletType.zcash) { + return zcash! + .isRotatingAddressOption(widget.receiveOptionViewModel.selectedReceiveOption); + } + return !vm.isPivxShieldedReceiveAddress; + } void _showLabelModal() { showMaterialModalBottomSheet( diff --git a/lib/new-ui/pages/send_page.dart b/lib/new-ui/pages/send_page.dart index 0a6ea20d6a..1cf4c5a41f 100644 --- a/lib/new-ui/pages/send_page.dart +++ b/lib/new-ui/pages/send_page.dart @@ -588,12 +588,29 @@ class _NewSendPageState extends State { onChanged: (value) => widget.sendViewModel.setAllowMwebCoins(value), ), + if (widget.sendViewModel.currency == CryptoCurrency.pivx) + StandardCheckbox( + caption: 'Send from shielded balance', + captionColor: Theme.of(context).colorScheme.onSurface, + borderColor: Theme.of(context).colorScheme.primary, + iconColor: Theme.of(context).colorScheme.primary, + value: widget.sendViewModel.coinTypeToSpendFrom == + UnspentCoinType.sapling, + onChanged: (value) => + widget.sendViewModel.setPivxCoinType(value + ? UnspentCoinType.sapling + : UnspentCoinType.transparent), + ), if (widget.sendViewModel.hasMemos) Observer( builder: (_) => NewSendMemoInput( memoController: _memoControllers[_selectedOutput], maxMemoLength: widget.sendViewModel.maxMemoLength, memoLength: output.memo.length, + disclaimerText: widget.sendViewModel.walletType == + WalletType.pivx + ? S.of(context).pivx_memo_disclaimer + : null, ), ), if (widget.sendViewModel.hasCoinControl || diff --git a/lib/new-ui/widgets/coins_page/cards/balance_card.dart b/lib/new-ui/widgets/coins_page/cards/balance_card.dart index 20b16ed653..a7e4fbb849 100644 --- a/lib/new-ui/widgets/coins_page/cards/balance_card.dart +++ b/lib/new-ui/widgets/coins_page/cards/balance_card.dart @@ -32,7 +32,8 @@ class BalanceCard extends StatelessWidget { this.capitalizeAssetName = true, this.onCustomizeTapped, this.accountIndex, - this.fiatFirst = false}); + this.fiatFirst = false, + this.subline = ""}); final double width; final double borderRadius; @@ -43,6 +44,8 @@ class BalanceCard extends StatelessWidget { final String fiatBalance; final String fiatCurrencyTitle; final bool fiatFirst; + + final String subline; final int? accountIndex; final bool capitalizeAssetName; final String assetName; @@ -229,6 +232,17 @@ class BalanceCard extends StatelessWidget { ), ), ), + if (subline.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + subline, + style: DefaultTextStyle.of(context).style.copyWith( + fontSize: 12, + fontWeight: FontWeight.w400, + color: design.colors.textColorSecondary), + ), + ), ], ) else diff --git a/lib/new-ui/widgets/coins_page/cards/cards_view.dart b/lib/new-ui/widgets/coins_page/cards/cards_view.dart index 1aec566a7c..1f4fdaefa9 100644 --- a/lib/new-ui/widgets/coins_page/cards/cards_view.dart +++ b/lib/new-ui/widgets/coins_page/cards/cards_view.dart @@ -144,7 +144,10 @@ class _CardsViewState extends State { late final String walletBalance; late final String walletFiatBalance; - if (widget.dashboardViewModel.mwebEnabled && widget.dashboardViewModel.hasMweb) { + if ((widget.dashboardViewModel.mwebEnabled && widget.dashboardViewModel.hasMweb) || + widget.dashboardViewModel.wallet.type == WalletType.pivx) { + // pivx (like MWEB) holds a shielded balance; show the combined + // transparent + shielded total. if (widget.dashboardViewModel.balanceViewModel.displayMode == BalanceDisplayMode.hiddenBalance) { walletBalance = '●●●●●●'; @@ -213,6 +216,23 @@ class _CardsViewState extends State { ] : []; + // pivx: shielded/transparent breakdown under the combined total. + String balanceSubline = ""; + if (widget.dashboardViewModel.wallet.type == WalletType.pivx && + widget.dashboardViewModel.balanceViewModel.displayMode != + BalanceDisplayMode.hiddenBalance) { + final shielded = walletBalanceRecord?.secondAvailableBalance ?? "0"; + final transparent = walletBalanceRecord?.availableBalance ?? "0"; + balanceSubline = + "${S.of(context).shielded} $shielded · ${S.of(context).transparent} $transparent"; + // shielded receives under receiveConfirmations sit in secondUnavailable. + // surface them so the confirmed-vs-total gap isn't silent. + final pending = walletBalanceRecord?.secondAdditionalBalance ?? "0"; + if (walletBalanceRecord?.raw.secondUnavailable?.isZero == false) { + balanceSubline += " · $pending${S.of(context).pending}"; + } + } + return BalanceCard( width: effectiveCardWidth, accountName: accountName, @@ -221,6 +241,7 @@ class _CardsViewState extends State { assetName: assetName, capitalizeAssetName: _shouldCapitalizeAssetName(), balance: walletBalance, + subline: balanceSubline, fiatCurrencyTitle: walletBalanceRecord?.fiatCurrency?.title ?? widget.dashboardViewModel.settingsStore.fiatCurrency.title, fiatFirst: widget.dashboardViewModel.balanceViewModel.showCombinedBalance, diff --git a/lib/pivx/cw_pivx.dart b/lib/pivx/cw_pivx.dart new file mode 100644 index 0000000000..d9958a42fc --- /dev/null +++ b/lib/pivx/cw_pivx.dart @@ -0,0 +1,190 @@ +part of 'pivx.dart'; + +class CWPivx extends Pivx { + @override + WalletService createPivxWalletService( + Box unspentCoinSource, bool isDirect) { + return PivxWalletService(unspentCoinSource, isDirect); + } + + @override + WalletCredentials createPivxNewWalletCredentials({ + required String name, + WalletInfo? walletInfo, + String? password, + String? passphrase, + String? mnemonic, + }) => + PivxNewWalletCredentials( + name: name, + walletInfo: walletInfo, + password: password, + passphrase: passphrase, + mnemonic: mnemonic, + ); + + @override + WalletCredentials createPivxRestoreWalletFromSeedCredentials({ + required String name, + required String mnemonic, + required String password, + String? passphrase, + int? height, + }) => + PivxRestoreWalletFromSeedCredentials( + name: name, + mnemonic: mnemonic, + password: password, + passphrase: passphrase, + height: height, + ); + + @override + TransactionPriority deserializePivxTransactionPriority(int raw) => + PivxTransactionPriority.deserialize(raw: raw); + + @override + TransactionPriority getDefaultTransactionPriority() => + PivxTransactionPriority.medium; + + @override + List getTransactionPriorities() => + PivxTransactionPriority.all; + + @override + TransactionPriority getPivxTransactionPrioritySlow() => + PivxTransactionPriority.slow; + + @override + int getHeightByDate({required DateTime date}) { + // pivx has ~60s blocks. anchor to an observed (height, date) and walk back + // at ~1440 blocks/day. underestimating is safe (scans a little extra); + // overestimating skips notes, so subtract a one-day margin and never return + // above the anchor. + const anchorHeight = 5552910; // observed db_height ~2026-08-23 + final anchorDate = DateTime.utc(2026, 8, 23); + const blocksPerDay = 1440; + // SaplingConstants.mainnetSaplingActivationHeight (not exported to app layer) + const activationFloor = 2700500; + + final daysBefore = anchorDate.difference(date).inDays; + final estimated = anchorHeight - (daysBefore + 1) * blocksPerDay; + if (estimated < activationFloor) return activationFloor; + if (estimated > anchorHeight) return anchorHeight; + return estimated; + } + + @override + String getShieldedAddress(Object wallet) { + final pivxWallet = wallet as PivxWallet; + return pivxWallet.currentShieldedAddress ?? ''; + } + + @override + bool isSaplingEnabled(Object wallet) { + final pivxWallet = wallet as PivxWallet; + return pivxWallet.saplingEnabled; + } + + @override + bool isShieldSyncing(Object wallet) { + final pivxWallet = wallet as PivxWallet; + return pivxWallet.isShieldSyncing; + } + + @override + bool isSaplingRpcAvailable(Object wallet) { + final pivxWallet = wallet as PivxWallet; + return pivxWallet.saplingRpcAvailable; + } + + @override + int getLastShieldSyncedBlock(Object wallet) { + final pivxWallet = wallet as PivxWallet; + return pivxWallet.lastShieldSyncedBlock; + } + + @override + String? getLastShieldSyncError(Object wallet) { + final pivxWallet = wallet as PivxWallet; + return pivxWallet.lastShieldSyncError; + } + + @override + int getShieldedBalance(Object wallet) { + final pivxWallet = wallet as PivxWallet; + return pivxWallet.shieldedBalance; + } + + @override + Future generateNewShieldedAddress(Object wallet, + {String? label}) async { + final pivxWallet = wallet as PivxWallet; + return await pivxWallet.generateNewShieldedAddress(label: label); + } + + @override + List> getShieldedAddresses(Object wallet) { + final pivxWallet = wallet as PivxWallet; + return pivxWallet.shieldedAddresses + .map((addr) => { + 'address': addr.address, + 'label': addr.label, + 'diversifierIndex': addr.diversifierIndex, + 'isDefault': addr.isDefault, + }) + .toList(); + } + + @override + Future updateShieldedAddressLabel(Object wallet, + {required String address, required String label}) async { + final pivxWallet = wallet as PivxWallet; + await pivxWallet.updateShieldedAddressLabel(address, label); + } + + @override + List getPivxReceivePageOptions(Object wallet) { + final pivxWallet = wallet as PivxWallet; + if (!pivxWallet.saplingEnabled) { + return [PivxReceivePageOption.transparent]; + } + return PivxReceivePageOption.all; + } + + @override + ReceivePageOption getSelectedAddressType(Object wallet) { + final pivxWallet = wallet as PivxWallet; + final addresses = pivxWallet.walletAddresses as PivxWalletAddresses; + return addresses.selectedShieldedAddress != null + ? PivxReceivePageOption.shieldedSapling + : PivxReceivePageOption.transparent; + } + + @override + bool isPivxReceivePageOption(ReceivePageOption option) => + option is PivxReceivePageOption; + + @override + dynamic getOptionToType(ReceivePageOption option) => + (option as PivxReceivePageOption).toType(); + + @override + Future setAddressType(Object wallet, dynamic option) async { + final pivxWallet = wallet as PivxWallet; + final addresses = pivxWallet.walletAddresses as PivxWalletAddresses; + if (option == PivxAddressType.shieldedSapling) { + final shielded = pivxWallet.currentShieldedAddress; + if (shielded != null && shielded.isNotEmpty) { + addresses.address = shielded; + } + } else { + addresses.clearShieldedSelection(); + } + } + + @override + Future checkNodeSupportsSapling( + {required Uri uri, bool? useSSL, required bool isTestnet}) => + pivxNodeSupportsSapling(uri: uri, useSSL: useSSL, isTestnet: isTestnet); +} diff --git a/lib/reactions/on_current_wallet_change.dart b/lib/reactions/on_current_wallet_change.dart index 2970d10706..5501145591 100644 --- a/lib/reactions/on_current_wallet_change.dart +++ b/lib/reactions/on_current_wallet_change.dart @@ -83,6 +83,7 @@ void startCurrentWalletChangeReaction( wallet.type == WalletType.litecoin || wallet.type == WalletType.bitcoinCash || wallet.type == WalletType.dogecoin || + wallet.type == WalletType.pivx || wallet.type == WalletType.decred) { _setAutoGenerateSubaddressStatus(wallet, settingsStore); } diff --git a/lib/reactions/wallet_utils.dart b/lib/reactions/wallet_utils.dart index 57d11a4c86..2f3d9edf2a 100644 --- a/lib/reactions/wallet_utils.dart +++ b/lib/reactions/wallet_utils.dart @@ -19,6 +19,7 @@ bool isBIP39Wallet(WalletType walletType) { case WalletType.zcash: case WalletType.zano: case WalletType.decred: + case WalletType.pivx: return true; case WalletType.wownero: case WalletType.haven: @@ -33,6 +34,7 @@ bool isElectrumWallet(WalletType walletType) { case WalletType.litecoin: case WalletType.bitcoinCash: case WalletType.dogecoin: + case WalletType.pivx: return true; default: return false; diff --git a/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart b/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart index ed85dc81c5..cf15b88c9c 100644 --- a/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart +++ b/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart @@ -62,6 +62,7 @@ class _DesktopWalletSelectionDropDownState extends State Image.asset( @@ -200,6 +201,8 @@ class _DesktopWalletSelectionDropDownState extends State launchUrl( + Uri.parse("https://docs.cakewallet.com/cryptos/litecoin#mweb"), + mode: LaunchMode.externalApplication, + ), + child: Row( + children: [ + labelText, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon( + Icons.help_outline, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + ], + ), + ); + } + + // For PIVX and other wallet types, just show the label without help icon + return labelText; + } } diff --git a/lib/src/screens/dashboard/widgets/menu_widget.dart b/lib/src/screens/dashboard/widgets/menu_widget.dart index fbdb058615..a97f390b2f 100644 --- a/lib/src/screens/dashboard/widgets/menu_widget.dart +++ b/lib/src/screens/dashboard/widgets/menu_widget.dart @@ -42,7 +42,8 @@ class MenuWidgetState extends State { this.zanoIcon = Image.asset('assets/new-ui/crypto_full_icons/zano.svg'), this.decredIcon = Image.asset('assets/new-ui/crypto_full_icons/decred.svg'), this.dogecoinIcon = Image.asset('assets/new-ui/crypto_full_icons/dogecoin.svg'), - this.zcashIcon = Image.asset('assets/new-ui/crypto_full_icons/zcash.svg'); + this.zcashIcon = Image.asset('assets/new-ui/crypto_full_icons/zcash.svg'), + this.pivxIcon = Image.asset('assets/images/pivx_icon.png'); final largeScreen = 731; @@ -74,6 +75,7 @@ class MenuWidgetState extends State { Image decredIcon; Image dogecoinIcon; Image zcashIcon; + Image pivxIcon; @override void initState() { @@ -273,6 +275,8 @@ class MenuWidgetState extends State { return dogecoinIcon; case WalletType.zcash: return zcashIcon; + case WalletType.pivx: + return pivxIcon; default: throw Exception('No icon for ${type.toString()}'); } diff --git a/lib/src/screens/dashboard/widgets/sync_indicator.dart b/lib/src/screens/dashboard/widgets/sync_indicator.dart index 3c6dd6ade8..3ce4e9bbe9 100644 --- a/lib/src/screens/dashboard/widgets/sync_indicator.dart +++ b/lib/src/screens/dashboard/widgets/sync_indicator.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/core/sync_status_title.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:cw_core/sync_status.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/sync_indicator_icon.dart'; class SyncIndicator extends StatelessWidget { @@ -20,7 +19,7 @@ class SyncIndicator extends StatelessWidget { return Observer(builder: (_) { final syncIndicatorWidth = 237.0; final status = dashboardViewModel.status; - final statusText = + final statusText = dashboardViewModel.pivxSyncIndicatorText ?? syncStatusTitle(status, dashboardViewModel.settingsStore.syncStatusDisplayMode); final progress = status.progress(); final indicatorOffset = progress * syncIndicatorWidth; @@ -61,18 +60,22 @@ class SyncIndicator extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ SyncIndicatorIcon( - isSynced: status is SyncedSyncStatus, + isSynced: dashboardViewModel.isSyncIndicatorSynced, showTorIcon: dashboardViewModel.builtinTor, size: dashboardViewModel.builtinTor ? 16 : 6, ), - Padding( - padding: EdgeInsets.only(left: 6), - child: Text( - statusText, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface, - ), + Flexible( + child: Padding( + padding: EdgeInsets.only(left: 6), + child: Text( + statusText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), ) ], diff --git a/lib/src/screens/nodes/widgets/node_list_row.dart b/lib/src/screens/nodes/widgets/node_list_row.dart index cbceaf7d8e..5776863848 100644 --- a/lib/src/screens/nodes/widgets/node_list_row.dart +++ b/lib/src/screens/nodes/widgets/node_list_row.dart @@ -2,6 +2,8 @@ import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/view_model/node_list/node_list_view_model.dart'; import 'package:cw_core/node.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_pivx/src/sapling/pivx_sapling_electrumx.dart'; import 'package:flutter/material.dart'; class NodeListRow extends StatelessWidget { diff --git a/lib/src/screens/receive/receive_page.dart b/lib/src/screens/receive/receive_page.dart index 030c10a7f6..cfbc3bf1ab 100644 --- a/lib/src/screens/receive/receive_page.dart +++ b/lib/src/screens/receive/receive_page.dart @@ -51,7 +51,8 @@ class ReceivePage extends BasePage { @override Widget Function(BuildContext, Widget) get rootWrapper => - (BuildContext context, Widget scaffold) => GradientBackground(scaffold: scaffold); + (BuildContext context, Widget scaffold) => + GradientBackground(scaffold: scaffold); @override Widget trailing(BuildContext context) { @@ -110,7 +111,9 @@ class ReceivePage extends BasePage { child: Text( addressListViewModel.isSilentPayments ? S.of(context).silent_payments_disclaimer - : S.of(context).electrum_address_disclaimer, + : addressListViewModel.isPivxShieldedReceiveAddress + ? S.of(context).pivx_shielded_receive_disclaimer + : S.of(context).electrum_address_disclaimer, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium!.copyWith( fontSize: 15, diff --git a/lib/src/screens/receive/widgets/address_cell.dart b/lib/src/screens/receive/widgets/address_cell.dart index fe26d87f02..f8255e1843 100644 --- a/lib/src/screens/receive/widgets/address_cell.dart +++ b/lib/src/screens/receive/widgets/address_cell.dart @@ -159,14 +159,15 @@ class AddressCell extends StatelessWidget { color: textColor, ), ), - Text( - '${S.of(context).transactions.toLowerCase()}: $txCount', - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - fontWeight: FontWeight.w600, - color: textColor, - ), - ), + if (txCount != null) + Text( + '${S.of(context).transactions.toLowerCase()}: $txCount', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), ], ), ), diff --git a/lib/src/screens/receive/widgets/address_list.dart b/lib/src/screens/receive/widgets/address_list.dart index 2f44b9a47e..eec42dc74e 100644 --- a/lib/src/screens/receive/widgets/address_list.dart +++ b/lib/src/screens/receive/widgets/address_list.dart @@ -9,6 +9,7 @@ import 'package:cake_wallet/src/widgets/section_divider.dart'; import 'package:cake_wallet/themes/core/material_base_theme.dart'; import 'package:cake_wallet/utils/list_item.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/wallet_address_list/address_edit_or_create_arguments.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_account_list_header.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_hidden_list_header.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_header.dart'; @@ -64,17 +65,32 @@ class _AddressListState extends State { items = getItems(widget.addressListViewModel.items, showHiddenAddresses); } + @override + void didUpdateWidget(AddressList oldWidget) { + super.didUpdateWidget(oldWidget); + // Refresh items when the widget is updated (e.g., balance changed) + if (oldWidget.addressListViewModel != widget.addressListViewModel) { + updateItems(); + } + } + @override Widget build(BuildContext context) { bool editable = widget.onSelect == null; - return ListView.separated( + // Wrap in Observer to react to balance changes (important for PIVX shielded balance) + return Observer( + builder: (_) { + // Access the items getter inside Observer to establish MobX dependency + final currentItems = getItems(widget.addressListViewModel.items, showHiddenAddresses); + + return ListView.separated( padding: EdgeInsets.all(0), separatorBuilder: (context, _) => const HorizontalSectionDivider(), shrinkWrap: true, physics: NeverScrollableScrollPhysics(), - itemCount: items.length, + itemCount: currentItems.length, itemBuilder: (context, index) { - final item = items[index]; + final item = currentItems[index]; Widget cell = Container(); if (item is WalletAccountListHeader) { @@ -124,15 +140,19 @@ class _AddressListState extends State { if (item is WalletAddressListHeader) { cell = HeaderTile( - title: S.of(context).addresses, + title: item.title ?? S.of(context).addresses, + subtitle: item.subtitle, + balance: item.balance, walletAddressListViewModel: widget.addressListViewModel, showTrailingButton: widget.addressListViewModel.showAddManualAddresses, - showSearchButton: true, + showSearchButton: !item.isShielded, // Only show search for transparent addresses onSearchCallback: updateItems, - trailingButtonTap: () => - Navigator.of(context).pushNamed(Routes.newSubaddress).then((value) { - updateItems(); // refresh the new address - }), + trailingButtonTap: () { + final args = AddressEditOrCreateArguments(isShielded: item.isShielded); + Navigator.of(context).pushNamed(Routes.newSubaddress, arguments: args).then((value) { + updateItems(); // refresh the new address + }); + }, trailingIcon: Icon( Icons.add, size: 20, @@ -200,6 +220,8 @@ class _AddressListState extends State { ); }, ); + }, // Close Observer builder + ); // Close Observer } void _hideAddress(WalletAddressListItem item) async { diff --git a/lib/src/screens/receive/widgets/header_tile.dart b/lib/src/screens/receive/widgets/header_tile.dart index 24918ad50e..ff1719490f 100644 --- a/lib/src/screens/receive/widgets/header_tile.dart +++ b/lib/src/screens/receive/widgets/header_tile.dart @@ -7,6 +7,8 @@ class HeaderTile extends StatefulWidget { HeaderTile({ required this.title, required this.walletAddressListViewModel, + this.subtitle, + this.balance, this.showSearchButton = false, this.showTrailingButton = false, this.trailingButtonTap, @@ -16,6 +18,13 @@ class HeaderTile extends StatefulWidget { final String title; final WalletAddressListViewModel walletAddressListViewModel; + + /// Optional subtitle shown below the title. + final String? subtitle; + + /// Optional balance to display on the right side of the header. + final String? balance; + final bool showSearchButton; final bool showTrailingButton; final VoidCallback? trailingButtonTap; @@ -58,12 +67,44 @@ class _HeaderTileState extends State { autofocus: true, ), ) - : Text( - widget.title, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - fontWeight: FontWeight.w600, + : Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + if (widget.balance != null) + Text( + widget.balance!, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], ), + if (widget.subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + widget.subtitle!, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), ), Row( children: [ diff --git a/lib/src/screens/receive/widgets/qr_widget.dart b/lib/src/screens/receive/widgets/qr_widget.dart index 5c2868c69e..47e150f563 100644 --- a/lib/src/screens/receive/widgets/qr_widget.dart +++ b/lib/src/screens/receive/widgets/qr_widget.dart @@ -9,6 +9,7 @@ import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.da import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; import 'package:cake_wallet/utils/address_formatter.dart'; import 'package:cake_wallet/utils/brightness_util.dart'; +import 'package:cake_wallet/utils/clipboard_util.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/utils/show_bar.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; @@ -223,8 +224,8 @@ class QRWidget extends StatelessWidget { child: Builder( builder: (context) => Observer( builder: (context) => GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: addressUri.address)); + onTap: () async { + await _copyReceiveData(addressUri.address); showBar(context, S.of(context).copied_to_clipboard); }, child: Row( @@ -253,14 +254,36 @@ class QRWidget extends StatelessWidget { ), ), ), + Observer( + builder: (_) => Offstage( + offstage: !addressListViewModel.isPivxShieldedReceiveAddress, + child: Padding( + padding: EdgeInsets.only(top: 12), + child: PrimaryImageButton( + onPressed: () async { + await _copyReceiveData(addressUri.toString()); + showBar(context, S.of(context).copied_to_clipboard); + }, + image: Image.asset( + 'assets/images/copy_address.png', + width: 25, + color: Theme.of(context).colorScheme.onSurface, + ), + text: S.of(context).copy_payment_uri, + color: Theme.of(context).colorScheme.surfaceContainer, + textColor: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ), Observer( builder: (_) => Offstage( offstage: !addressListViewModel.isPayjoinAvailable, child: Padding( padding: EdgeInsets.only(top: 12), child: PrimaryImageButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: addressUri.toString())); + onPressed: () async { + await _copyReceiveData(addressUri.toString()); showBar(context, S.of(context).copied_to_clipboard); }, image: Image.asset( @@ -288,6 +311,17 @@ class QRWidget extends StatelessWidget { return addressListViewModel.selectedCurrency.name.toUpperCase(); } + Future _copyReceiveData(String text) async { + final clipboardData = ClipboardData(text: text); + + if (addressListViewModel.isPivxShieldedReceiveAddress) { + await ClipboardUtil.setSensitiveDataToClipboard(clipboardData); + return; + } + + await Clipboard.setData(clipboardData); + } + void _presentPicker(BuildContext context) async { await showPopUp( builder: (_) => CurrencyPicker( diff --git a/lib/src/screens/restore/wallet_restore_page.dart b/lib/src/screens/restore/wallet_restore_page.dart index af8e9b91e7..1fcb8f105f 100644 --- a/lib/src/screens/restore/wallet_restore_page.dart +++ b/lib/src/screens/restore/wallet_restore_page.dart @@ -567,7 +567,8 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody> onHeightOrDateEntered: (value) { // set button state if (_isValidSeed()) { - widget.walletRestoreViewModel.isButtonEnabled = value; + widget.walletRestoreViewModel.isButtonEnabled = + widget.walletRestoreViewModel.type == WalletType.pivx || value; } }, onSeedChange: (String seed) { @@ -587,7 +588,9 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody> } void _validateOnChange({bool isPolyseed = false}) { - if (!isPolyseed && walletRestoreViewModel.hasBlockchainHeightSelector) { + if (!isPolyseed && + walletRestoreViewModel.hasBlockchainHeightSelector && + walletRestoreViewModel.type != WalletType.pivx) { final hasHeight = walletRestoreFromSeedFormKey .currentState?.blockchainHeightKey.currentState?.restoreHeightController.text.isNotEmpty; diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index 3f1682b117..1a09caa795 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -16,6 +16,7 @@ import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; import 'package:cake_wallet/themes/core/material_base_theme.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; +import 'package:cake_wallet/utils/show_bar.dart'; import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart'; import 'package:cake_wallet/exchange/trade.dart'; @@ -499,6 +500,11 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin( + context, S.of(context).pivx_payment_uri_unsupported_parameters); + } addressController.text = paymentRequest.address; if (paymentRequest.amount.isNotEmpty) { cryptoAmountController.text = paymentRequest.amount; @@ -911,6 +917,99 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin Padding( + padding: EdgeInsets.only(top: 14), + child: GestureDetector( + key: ValueKey('send_page_pivx_shielded_toggle_key'), + onTap: () { + final isShielded = + widget.sendViewModel.coinTypeToSpendFrom == + UnspentCoinType.sapling; + sendViewModel.setPivxCoinType(isShielded + ? UnspentCoinType.transparent + : UnspentCoinType.sapling); + }, + child: Container( + color: Colors.transparent, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StandardCheckbox( + caption: 'Send from shielded balance', + captionColor: Theme.of(context) + .colorScheme + .onSurfaceVariant, + borderColor: + Theme.of(context).colorScheme.primary, + iconColor: Theme.of(context).colorScheme.primary, + value: widget.sendViewModel.coinTypeToSpendFrom == + UnspentCoinType.sapling, + onChanged: (bool? value) { + sendViewModel.setPivxCoinType((value ?? false) + ? UnspentCoinType.sapling + : UnspentCoinType.transparent); + }, + ), + ], + ), + ), + ), + ), + ), + if (sendViewModel.currency == CryptoCurrency.pivx) + Observer( + builder: (_) { + final routeMessage = + widget.sendViewModel.pivxUnsupportedRouteMessage; + if (routeMessage == null) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + routeMessage, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + }, + ), ], ), ), diff --git a/lib/src/screens/wallet_keys/wallet_keys_page.dart b/lib/src/screens/wallet_keys/wallet_keys_page.dart index 2fb7690197..94cba7c2a7 100644 --- a/lib/src/screens/wallet_keys/wallet_keys_page.dart +++ b/lib/src/screens/wallet_keys/wallet_keys_page.dart @@ -184,6 +184,16 @@ class _WalletKeysPageBodyState extends State _buildHeightBox(), const SizedBox(height: 20), ], + if (!isLegacySeed && widget.walletKeysViewModel.isPivx) ...[ + WarningBox( + key: const ValueKey('wallet_keys_page_pivx_seed_only_notice_key'), + content: S.of(context).pivx_seed_only_recovery_notice, + iconSize: 28, + textAlign: TextAlign.start, + textWeight: FontWeight.w600, + ), + const SizedBox(height: 20), + ], (_buildPassphraseBox() ?? Container()), if (widget.walletKeysViewModel.passphrase.isNotEmpty) const SizedBox(height: 20), Expanded( diff --git a/lib/src/widgets/blockchain_height_widget.dart b/lib/src/widgets/blockchain_height_widget.dart index 68160c268b..06e567004e 100644 --- a/lib/src/widgets/blockchain_height_widget.dart +++ b/lib/src/widgets/blockchain_height_widget.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/monero/monero.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; import 'package:cake_wallet/decred/decred.dart'; @@ -203,6 +204,8 @@ class BlockchainHeightState extends State { height = await zcash!.getHeightByDate(date); } else if (widget.walletType == WalletType.zano) { height = zano!.getHeightByDate(date: date); + } else if (widget.walletType == WalletType.pivx) { + height = pivx!.getHeightByDate(date: date); } else { throw Exception("unknown currency in BlockchainHeightWidget"); } diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index bbbffa0c0a..e50aa625d4 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -33,6 +33,7 @@ import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/wownero/wownero.dart'; import 'package:cake_wallet/zano/zano.dart'; import 'package:cake_wallet/zcash/zcash.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart'; import 'package:cake_wallet/monero/monero.dart'; @@ -170,6 +171,7 @@ abstract class SettingsStoreBase with Store { TransactionPriority? initialDecredTransactionPriority, TransactionPriority? initialZcashTransactionPriority, TransactionPriority? initialDogecoinTransactionPriority, + TransactionPriority? initialPivxTransactionPriority, Country? initialCakePayCountry}) : nodes = ObservableMap.of(nodes), powNodes = ObservableMap.of(powNodes), @@ -279,6 +281,10 @@ abstract class SettingsStoreBase with Store { priority[WalletType.dogecoin] = initialDogecoinTransactionPriority; } + if (initialPivxTransactionPriority != null) { + priority[WalletType.pivx] = initialPivxTransactionPriority; + } + if (initialCakePayCountry != null) { selectedCakePayCountry = initialCakePayCountry; } @@ -353,6 +359,9 @@ abstract class SettingsStoreBase with Store { case WalletType.dogecoin: key = PreferencesKey.dogecoinTransactionPriority; break; + case WalletType.pivx: + key = PreferencesKey.pivxTransactionPriority; + break; default: key = null; } @@ -1203,6 +1212,7 @@ abstract class SettingsStoreBase with Store { TransactionPriority? decredTransactionPriority; TransactionPriority? zcashTransactionPriority; TransactionPriority? dogecoinTransactionPriority; + TransactionPriority? pivxTransactionPriority; if (sharedPreferences.getInt(PreferencesKey.havenTransactionPriority) != null) { havenTransactionPriority = monero?.deserializeMoneroTransactionPriority( @@ -1254,6 +1264,10 @@ abstract class SettingsStoreBase with Store { dogecoinTransactionPriority = dogecoin?.deserializeDogeCoinTransactionPriority( sharedPreferences.getInt(PreferencesKey.dogecoinTransactionPriority)!); } + if (sharedPreferences.getInt(PreferencesKey.pivxTransactionPriority) != null) { + pivxTransactionPriority = pivx?.deserializePivxTransactionPriority( + sharedPreferences.getInt(PreferencesKey.pivxTransactionPriority)!); + } moneroTransactionPriority ??= monero?.getDefaultTransactionPriority(); bitcoinTransactionPriority ??= bitcoin?.getMediumTransactionPriority(); @@ -1270,6 +1284,7 @@ abstract class SettingsStoreBase with Store { zanoTransactionPriority ??= zano?.getDefaultTransactionPriority(); zcashTransactionPriority ??= zcash?.getDefaultTransactionPriority(); dogecoinTransactionPriority ??= dogecoin?.getDefaultTransactionPriority(); + pivxTransactionPriority ??= pivx?.getDefaultTransactionPriority(); final currentBalanceDisplayMode = BalanceDisplayMode.deserialize( raw: sharedPreferences.getInt(PreferencesKey.currentBalanceDisplayModeKey)!); @@ -1401,6 +1416,7 @@ abstract class SettingsStoreBase with Store { final zcashNodeId = sharedPreferences.getInt(PreferencesKey.currentZcashNodeIdKey); final decredNodeId = sharedPreferences.getInt(PreferencesKey.currentDecredNodeIdKey); final dogecoinNodeId = sharedPreferences.getInt(PreferencesKey.currentDogecoinNodeIdKey); + final pivxNodeId = sharedPreferences.getInt(PreferencesKey.currentPivxNodeIdKey); final nodeSource = await Node.getAll(); final powNodeSource = await Node.getAllPow(); @@ -1445,6 +1461,8 @@ abstract class SettingsStoreBase with Store { nodeSource.firstWhereOrNull((e) => e.uriRaw == zcashDefaultNodeUri); final bscNode = nodeSource.firstWhereOrNull((e) => e.id == bscNodeId) ?? nodeSource.firstWhereOrNull((e) => e.uriRaw == bscDefaultNodeUri); + final pivxNode = nodeSource.firstWhereOrNull((e) => e.id == pivxNodeId) ?? + nodeSource.firstWhereOrNull((e) => e.uriRaw == pivxDefaultNodeUri); final packageInfo = await PackageInfo.fromPlatform(); final deviceName = await _getDeviceName() ?? ''; @@ -1553,6 +1571,10 @@ abstract class SettingsStoreBase with Store { nodes[WalletType.dogecoin] = dogecoinNode; } + if (pivxNode != null) { + nodes[WalletType.pivx] = pivxNode; + } + final savedSyncMode = SyncMode.all.firstWhere((element) { return element.type.index == (sharedPreferences.getInt(PreferencesKey.syncModeKey) ?? 2); // default to 2 - daily sync @@ -1759,6 +1781,7 @@ abstract class SettingsStoreBase with Store { initialDecredTransactionPriority: decredTransactionPriority, initialZcashTransactionPriority: zcashTransactionPriority, initialDogecoinTransactionPriority: dogecoinTransactionPriority, + initialPivxTransactionPriority: pivxTransactionPriority, initialShouldRequireTOTP2FAForAccessingWallet: shouldRequireTOTP2FAForAccessingWallet, initialShouldRequireTOTP2FAForSendsToContact: shouldRequireTOTP2FAForSendsToContact, initialShouldRequireTOTP2FAForSendsToNonContact: shouldRequireTOTP2FAForSendsToNonContact, @@ -1867,6 +1890,11 @@ abstract class SettingsStoreBase with Store { priority[WalletType.dogecoin] = dogecoin!.deserializeDogeCoinTransactionPriority( sharedPreferences.getInt(PreferencesKey.dogecoinTransactionPriority)!); } + if (pivx != null && + sharedPreferences.getInt(PreferencesKey.pivxTransactionPriority) != null) { + priority[WalletType.pivx] = pivx!.deserializePivxTransactionPriority( + sharedPreferences.getInt(PreferencesKey.pivxTransactionPriority)!); + } final generateSubaddresses = sharedPreferences.getInt(PreferencesKey.autoGenerateSubaddressStatusKey); @@ -2027,6 +2055,8 @@ abstract class SettingsStoreBase with Store { final zcashNode = await Node.get(zcashNodeId ?? -1); final decredNode = await Node.get(decredNodeId ?? -1); final dogecoinNode = await Node.get(dogecoinNodeId ?? -1); + final pivxNodeId = sharedPreferences.getInt(PreferencesKey.currentPivxNodeIdKey); + final pivxNode = await Node.get(pivxNodeId ?? -1); if (moneroNode != null) { nodes[WalletType.monero] = moneroNode; @@ -2100,6 +2130,10 @@ abstract class SettingsStoreBase with Store { nodes[WalletType.dogecoin] = dogecoinNode; } + if (pivxNode != null) { + nodes[WalletType.pivx] = pivxNode; + } + // MIGRATED: useTOTP2FA = await SecureKey.getBool( @@ -2249,6 +2283,9 @@ abstract class SettingsStoreBase with Store { case WalletType.zcash: await _sharedPreferences.setInt(PreferencesKey.currentZcashNodeIdKey, node.id); break; + case WalletType.pivx: + await _sharedPreferences.setInt(PreferencesKey.currentPivxNodeIdKey, node.id); + break; case WalletType.none: throw UnimplementedError(); case WalletType.banano: diff --git a/lib/utils/payment_request.dart b/lib/utils/payment_request.dart index d4452bfc4b..9a3164826a 100644 --- a/lib/utils/payment_request.dart +++ b/lib/utils/payment_request.dart @@ -19,6 +19,7 @@ class PaymentRequest { this.contractAddress, this.chainId, this.rawTokenAmount, + this.unsupportedParameters = const [], }); factory PaymentRequest.fromString(String input) { @@ -50,6 +51,7 @@ class PaymentRequest { String? contractAddress; int? chainId; String? rawTokenAmount; + final unsupportedParameters = []; if (uri != null) { if (uri.queryParameters["pj"] != null) { @@ -93,6 +95,14 @@ class PaymentRequest { contractAddress = splToken; } } + + if (scheme == "pivx") { + for (final key in uri.queryParameters.keys) { + if (key == 'memo' || key == 'req-memo' || key.startsWith('req-')) { + unsupportedParameters.add(key); + } + } + } } if (scheme == "nano-gpt") { @@ -103,9 +113,11 @@ class PaymentRequest { if (amount.isNotEmpty) { if (!_isAlreadyUsableAmount(amount)) { if (address.contains("nano")) { - amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerNano); + amount = + nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerNano); } else if (address.contains("ban")) { - amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerBanano); + amount = + nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerBanano); } } } @@ -122,6 +134,7 @@ class PaymentRequest { contractAddress: contractAddress, chainId: chainId, rawTokenAmount: rawTokenAmount, + unsupportedParameters: unsupportedParameters, ); } @@ -148,6 +161,7 @@ class PaymentRequest { contractAddress: contractAddress ?? this.contractAddress, chainId: chainId ?? this.chainId, rawTokenAmount: rawTokenAmount ?? this.rawTokenAmount, + unsupportedParameters: unsupportedParameters, ); final String address; @@ -160,6 +174,9 @@ class PaymentRequest { final String? contractAddress; final int? chainId; final String? rawTokenAmount; + final List unsupportedParameters; + + bool get hasUnsupportedParameters => unsupportedParameters.isNotEmpty; String? resolveTokenAmount(CryptoCurrency token) { if (amount.isNotEmpty) { diff --git a/lib/utils/qr_util.dart b/lib/utils/qr_util.dart index f03937c32a..2e092608d8 100644 --- a/lib/utils/qr_util.dart +++ b/lib/utils/qr_util.dart @@ -36,6 +36,8 @@ String getQrImage(WalletType type) { return 'assets/images/doge_chain_qr.svg'; case WalletType.zcash: return 'assets/images/zec_icon_qr.svg'; + case WalletType.pivx: + return 'assets/images/pivx_chain_qr.svg'; case WalletType.banano: case WalletType.haven: case WalletType.none: diff --git a/lib/view_model/advanced_privacy_settings_view_model.dart b/lib/view_model/advanced_privacy_settings_view_model.dart index f8f1ce1d75..0960c98ddb 100644 --- a/lib/view_model/advanced_privacy_settings_view_model.dart +++ b/lib/view_model/advanced_privacy_settings_view_model.dart @@ -52,6 +52,7 @@ abstract class AdvancedPrivacySettingsViewModelBase with Store { case WalletType.ethereum: case WalletType.bitcoinCash: case WalletType.dogecoin: + case WalletType.pivx: case WalletType.polygon: case WalletType.base: case WalletType.arbitrum: @@ -108,6 +109,7 @@ abstract class AdvancedPrivacySettingsViewModelBase with Store { WalletType.dogecoin, WalletType.zcash, WalletType.decred, + WalletType.pivx, ].contains(type); @computed diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart index 3128f74d8f..14b25aa547 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -235,6 +235,8 @@ abstract class BalanceViewModelBase with Store { switch (wallet.type) { case WalletType.litecoin: return S.current.mweb_confirmed; + case WalletType.pivx: + return S.current.shielded; default: return S.current.confirmed; } @@ -245,6 +247,8 @@ abstract class BalanceViewModelBase with Store { switch (wallet.type) { case WalletType.litecoin: return S.current.mweb_unconfirmed; + case WalletType.pivx: + return S.current.shielded_unconfirmed; default: return S.current.unconfirmed; } @@ -351,6 +355,8 @@ abstract class BalanceViewModelBase with Store { return (wallet.balance[CryptoCurrency.ltc]?.secondUnavailable ?? 0) != 0; } else if (wallet.type == WalletType.bitcoin) { return (wallet.balance[CryptoCurrency.btc]?.secondUnavailable ?? 0) != 0; + } else if (wallet.type == WalletType.pivx) { + return (wallet.balance[CryptoCurrency.pivx]?.secondAvailable ?? 0) != 0; } return false; } @@ -362,6 +368,8 @@ abstract class BalanceViewModelBase with Store { return true; case WalletType.litecoin: return mwebEnabled; + case WalletType.pivx: + return true; default: return false; } diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 1c71ce11e3..6f990d6a0f 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -20,6 +20,7 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/order/order_provider_description.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/store/dashboard/order_filter_store.dart'; import 'package:cake_wallet/utils/device_info.dart'; @@ -614,6 +615,91 @@ abstract class DashboardViewModelBase with Store { @computed SyncStatus get status => wallet.syncStatus; + String? get pivxSyncIndicatorText { + if (wallet.type != WalletType.pivx || + !(pivx?.isSaplingEnabled(wallet) ?? false)) { + return null; + } + + final error = pivx?.getLastShieldSyncError(wallet); + if (error != null && error.isNotEmpty) { + return 'Shielded sync failed'; + } + + final lastShieldHeight = pivx?.getLastShieldSyncedBlock(wallet) ?? 0; + if (pivx?.isShieldSyncing(wallet) ?? false) { + return lastShieldHeight > 0 + ? 'Shielded syncing $lastShieldHeight' + : 'Shielded syncing'; + } + + if (lastShieldHeight > 0 && status is SyncedSyncStatus) { + return 'Shielded synced $lastShieldHeight'; + } + + return null; + } + + bool get isSyncIndicatorSynced { + if (wallet.type == WalletType.pivx) { + final error = pivx?.getLastShieldSyncError(wallet); + if (error != null && error.isNotEmpty) { + return false; + } + if (pivx?.isShieldSyncing(wallet) ?? false) { + return false; + } + } + + return status is SyncedSyncStatus; + } + + String? get pivxShieldedStatusValue { + if (wallet.type != WalletType.pivx || + !(pivx?.isSaplingEnabled(wallet) ?? false)) { + return null; + } + + final error = pivx?.getLastShieldSyncError(wallet); + if (error != null && error.isNotEmpty) { + return error; + } + + final lastShieldHeight = pivx?.getLastShieldSyncedBlock(wallet) ?? 0; + if (pivx?.isShieldSyncing(wallet) ?? false) { + return lastShieldHeight > 0 + ? 'Scanning shielded block $lastShieldHeight' + : 'Scanning shielded blocks'; + } + + if (pivx?.isSaplingRpcAvailable(wallet) ?? false) { + return lastShieldHeight > 0 + ? 'Sapling RPC ready, scanned to $lastShieldHeight' + : 'Sapling RPC ready'; + } + + return 'Sapling RPC not verified'; + } + + String? get pivxShieldedStatusIndicator { + if (wallet.type != WalletType.pivx || + !(pivx?.isSaplingEnabled(wallet) ?? false)) { + return null; + } + + final error = pivx?.getLastShieldSyncError(wallet); + if (error != null && error.isNotEmpty) { + return 'failed'; + } + if (pivx?.isShieldSyncing(wallet) ?? false) { + return 'fetching'; + } + if (pivx?.isSaplingRpcAvailable(wallet) ?? false) { + return 'success'; + } + return 'waiting'; + } + @computed bool get shouldShowMwebAd { return false; @@ -1253,6 +1339,7 @@ abstract class DashboardViewModelBase with Store { case WalletType.wownero: case WalletType.decred: case WalletType.dogecoin: + case WalletType.pivx: return true; case WalletType.zano: case WalletType.haven: diff --git a/lib/view_model/dashboard/home_settings_view_model.dart b/lib/view_model/dashboard/home_settings_view_model.dart index 5039c927bf..37218b2e15 100644 --- a/lib/view_model/dashboard/home_settings_view_model.dart +++ b/lib/view_model/dashboard/home_settings_view_model.dart @@ -242,6 +242,7 @@ abstract class HomeSettingsViewModelBase with Store { case WalletType.decred: case WalletType.dogecoin: case WalletType.zcash: + case WalletType.pivx: return false; } @@ -283,6 +284,7 @@ abstract class HomeSettingsViewModelBase with Store { case WalletType.decred: case WalletType.dogecoin: case WalletType.zcash: + case WalletType.pivx: return false; } diff --git a/lib/view_model/dashboard/receive_option_view_model.dart b/lib/view_model/dashboard/receive_option_view_model.dart index f82fd9e4c2..6c55d381a1 100644 --- a/lib/view_model/dashboard/receive_option_view_model.dart +++ b/lib/view_model/dashboard/receive_option_view_model.dart @@ -1,4 +1,5 @@ import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cake_wallet/zcash/zcash.dart'; import 'package:cw_core/receive_page_option.dart'; import 'package:cw_core/wallet_base.dart'; @@ -18,7 +19,9 @@ abstract class ReceiveOptionViewModelBase with Store { ? ReceivePageOption.testnet : _wallet.type == WalletType.zcash ? zcash!.getSelectedAddressType(_wallet) - : ReceivePageOption.mainnet) { + : _wallet.type == WalletType.pivx + ? pivx!.getSelectedAddressType(_wallet) + : ReceivePageOption.mainnet) { if (_wallet.type == WalletType.zcash) { reaction( (_) => zcash!.ironwoodActive(_wallet.walletAddresses), diff --git a/lib/view_model/dashboard/sign_view_model.dart b/lib/view_model/dashboard/sign_view_model.dart index a175822c82..e12df29cc2 100644 --- a/lib/view_model/dashboard/sign_view_model.dart +++ b/lib/view_model/dashboard/sign_view_model.dart @@ -25,6 +25,7 @@ abstract class SignViewModelBase with Store { WalletType.bitcoinCash, WalletType.litecoin, WalletType.dogecoin, + WalletType.pivx, WalletType.haven, ].contains(wallet.type); diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index 620dcd1e1e..917f570749 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -60,6 +60,18 @@ class TransactionListItem extends ActionListItem with Keyable { return 'Transaction has missing data'; } + if (balanceViewModel.wallet.type == WalletType.pivx && + transaction.additionalInfo['isPivxShielded'] == true) { + if (transaction.direction == TransactionDirection.outgoing) { + // t->z spends TRANSPARENT into a shielded output; label it a shield so + // it doesn't read as a shielded-source send like z->z / z->t. + if (transaction.additionalInfo['pivxRoute'] == 't-to-z') { + return 'Shielded'; + } + return '${S.current.sent} shielded'; + } + return '${S.current.received} shielded'; + } if (transaction.additionalInfo['isIronwoodMigration'] == true) { return 'Migration'; } @@ -138,6 +150,13 @@ class TransactionListItem extends ActionListItem with Keyable { str += " (Unmask)"; } return str; + case WalletType.pivx: + if (transaction.additionalInfo['isPivxShielded'] == true && + transaction.confirmations >= 0 && + transaction.confirmations < 6) { + return ' (${transaction.confirmations}/6)'; + } + break; default: return ''; } @@ -151,6 +170,7 @@ class TransactionListItem extends ActionListItem with Keyable { WalletType.haven, WalletType.wownero, WalletType.litecoin, + WalletType.pivx, WalletType.zano, ].contains(balanceViewModel.wallet.type)) { return formattedPendingStatus; @@ -202,6 +222,7 @@ class TransactionListItem extends ActionListItem with Keyable { case WalletType.nano: case WalletType.decred: case WalletType.zcash: + case WalletType.pivx: amount = calculateFiatAmountRaw( cryptoAmount: double.parse(transaction.amount.toString()), price: price, diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index dfc0d56602..e465a69eef 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -467,6 +467,8 @@ abstract class ExchangeTradeViewModelBase with Store { return LitecoinURI(amount: amount, address: inputAddress); case WalletType.nano: return NanoURI(amount: amount, address: inputAddress); + case WalletType.pivx: + return PivxURI(amount: amount, address: inputAddress); case WalletType.zano: return ZanoURI(amount: amount, address: inputAddress); case WalletType.decred: diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index 5b6ee9e102..baf917feb8 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -288,7 +288,8 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, - WalletType.dogecoin + WalletType.dogecoin, + WalletType.pivx ].contains(wallet.type); bool get hideAddressAfterExchange => @@ -554,6 +555,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin, + WalletType.pivx, ].contains(wallet.type)) return (depositCurrency == wallet.currency); if (!isEVMCompatibleChain(wallet.type)) return false; @@ -1380,6 +1382,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with WalletType.bitcoin, WalletType.bitcoinCash, WalletType.dogecoin, + WalletType.pivx, ].contains(wallet.type)) { final priority = _settingsStore.getPriority(wallet.type)!; @@ -1544,6 +1547,10 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with depositCurrency = CryptoCurrency.doge; receiveCurrency = CryptoCurrency.xmr; break; + case WalletType.pivx: + depositCurrency = CryptoCurrency.pivx; + receiveCurrency = CryptoCurrency.xmr; + break; case WalletType.haven: depositCurrency = CryptoCurrency.xhv; receiveCurrency = CryptoCurrency.btc; diff --git a/lib/view_model/node_list/node_create_or_edit_view_model.dart b/lib/view_model/node_list/node_create_or_edit_view_model.dart index 00fb961c1c..5b2b523568 100644 --- a/lib/view_model/node_list/node_create_or_edit_view_model.dart +++ b/lib/view_model/node_list/node_create_or_edit_view_model.dart @@ -139,6 +139,7 @@ abstract class NodeCreateOrEditViewModelBase with Store { case WalletType.bitcoinCash: case WalletType.bitcoin: case WalletType.dogecoin: + case WalletType.pivx: case WalletType.zano: case WalletType.decred: case WalletType.zcash: @@ -290,6 +291,8 @@ abstract class NodeCreateOrEditViewModelBase with Store { try { connectionState = IsExecutingState(); final isAlive = await node.requestNode(); + // PIVX Sapling node-capability probing on add is deferred; the wallet + // still probes and validates the v1 contract at sync time. connectionState = ExecutedSuccessfullyState(payload: isAlive); } catch (e) { connectionState = FailureState(e.toString()); diff --git a/lib/view_model/payment/payment_view_model.dart b/lib/view_model/payment/payment_view_model.dart index 50887d2b1e..7311110247 100644 --- a/lib/view_model/payment/payment_view_model.dart +++ b/lib/view_model/payment/payment_view_model.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/core/universal_address_detector.dart'; import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; @@ -66,6 +67,24 @@ abstract class PaymentViewModelBase with Store { detectedWalletType = null; isProcessing = true; + final currentWallet = appStore.wallet; + + // a base58 D... addr is identical between pivx and dogecoin (both version + // byte 30), so the detector flags pivx as doge and offers a swap. if the + // addr is valid for the current wallet it's a normal send, skip detection. + bool isCurrentWalletAddress(String? candidate) => + currentWallet != null && + candidate != null && + candidate.trim().isNotEmpty && + AddressValidator( + type: currentWallet.currency, + isTestnet: currentWallet.isTestnet, + ).isValid(candidate.trim()); + + if (isCurrentWalletAddress(addressData)) { + return PaymentFlowResult.currentWalletCompatible(); + } + // Detect address type final detectionResult = UniversalAddressDetector.detectAddress(addressData); @@ -76,6 +95,11 @@ abstract class PaymentViewModelBase with Store { return PaymentFlowResult.incompatible('Unable to detect address type'); } + // re-check the extracted addr (handles pivx: URIs) - still a normal send. + if (isCurrentWalletAddress(detectionResult.address)) { + return PaymentFlowResult.currentWalletCompatible(); + } + if (detectedWalletType == WalletType.solana && solana == null) { return PaymentFlowResult.incompatible('Solana is not available in this app build.'); } @@ -88,8 +112,6 @@ abstract class PaymentViewModelBase with Store { ); } - final currentWallet = appStore.wallet; - if (currentWallet != null && currentWallet.type == detectedWalletType && !isEVMCompatibleChain(detectedWalletType!)) { @@ -237,7 +259,7 @@ class PaymentFlowResult { /// Current wallet is compatible factory PaymentFlowResult.currentWalletCompatible( - AddressDetectionResult addressDetectionResult) => + [AddressDetectionResult? addressDetectionResult]) => PaymentFlowResult._( type: PaymentFlowType.currentWalletCompatible, addressDetectionResult: addressDetectionResult, diff --git a/lib/view_model/restore/wallet_restore_from_qr_code.dart b/lib/view_model/restore/wallet_restore_from_qr_code.dart index f1456b9b5f..4c3ebc1d72 100644 --- a/lib/view_model/restore/wallet_restore_from_qr_code.dart +++ b/lib/view_model/restore/wallet_restore_from_qr_code.dart @@ -59,6 +59,9 @@ class WalletRestoreFromQRCode { 'zcash': WalletType.zcash, 'zcash-wallet': WalletType.zcash, 'zcash_wallet': WalletType.zcash, + 'pivx': WalletType.pivx, + 'pivx-wallet': WalletType.pivx, + 'pivx_wallet': WalletType.pivx, }; static WalletType? _extractWalletType(String code) { @@ -181,6 +184,16 @@ class WalletRestoreFromQRCode { throw Exception('Unexpected restore mode: tx_payment_id is invalid'); } + if (type == WalletType.pivx && + (credentials.containsKey('private_key') || + credentials.containsKey('spend_key') || + credentials.containsKey('view_key') || + credentials.containsKey('xpub') || + credentials.containsKey('zpub'))) { + throw UnsupportedError( + 'PIVX key restore is not supported yet. Restore PIVX wallets from seed phrase and optional restore height.'); + } + if (credentials.containsKey("xpub") || credentials.containsKey("zpub")) { return WalletRestoreMode.keys; } diff --git a/lib/view_model/send/fees_view_model.dart b/lib/view_model/send/fees_view_model.dart index c9fb0b5d53..e84341dc69 100644 --- a/lib/view_model/send/fees_view_model.dart +++ b/lib/view_model/send/fees_view_model.dart @@ -2,6 +2,7 @@ import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; import 'package:cake_wallet/core/amount_parsing_proxy.dart'; import 'package:cake_wallet/decred/decred.dart'; import 'package:cake_wallet/dogecoin/dogecoin.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cake_wallet/entities/priority_for_wallet_type.dart'; import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; import 'package:cake_wallet/evm/evm.dart'; @@ -103,6 +104,8 @@ abstract class FeesViewModelBase extends WalletChangeListenerViewModel with Stor return transactionPriority == decred!.getDecredTransactionPrioritySlow(); case WalletType.dogecoin: return transactionPriority == dogecoin!.getDogeCoinTransactionPrioritySlow(); + case WalletType.pivx: + return transactionPriority == pivx!.getPivxTransactionPrioritySlow(); case WalletType.none: case WalletType.nano: case WalletType.banano: @@ -128,6 +131,8 @@ abstract class FeesViewModelBase extends WalletChangeListenerViewModel with Stor wallet.type != WalletType.banano && wallet.type != WalletType.solana && wallet.type != WalletType.tron && + // PIVX uses a fixed low min-relay fee; no priority selector needed. + wallet.type != WalletType.pivx && wallet.chainId != 42161; // Wallet type is generic for all EVM chains, so we need to check the chainId @@ -136,7 +141,8 @@ abstract class FeesViewModelBase extends WalletChangeListenerViewModel with Stor wallet.type == WalletType.bitcoin || wallet.type == WalletType.litecoin || wallet.type == WalletType.bitcoinCash || - wallet.type == WalletType.dogecoin; + wallet.type == WalletType.dogecoin || + wallet.type == WalletType.pivx; String? get walletCurrencyName => wallet.currency.fullName?.toLowerCase() ?? wallet.currency.name; @@ -216,6 +222,9 @@ abstract class FeesViewModelBase extends WalletChangeListenerViewModel with Stor case WalletType.dogecoin: _settingsStore.setPriority(wallet.type, dogecoin!.getDefaultTransactionPriority()); break; + case WalletType.pivx: + _settingsStore.setPriority(wallet.type, pivx!.getDefaultTransactionPriority()); + break; default: break; } diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 618416b95b..2edfd05620 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -151,6 +151,7 @@ abstract class OutputBase with Store { case WalletType.dogecoin: case WalletType.decred: case WalletType.zano: + case WalletType.pivx: estimatedFee = Money.fromInt(fee, walletTypeToCryptoCurrency(_wallet.type)); break; case WalletType.bitcoin: diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 3d32febfdb..6de9567a0b 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -79,6 +79,16 @@ part 'send_view_model.g.dart'; class SendViewModel = SendViewModelBase with _$SendViewModel; +enum PivxSendRouteStatus { + incomplete, + transparentToTransparent, + shieldedToShielded, + shieldedToTransparent, + transparentToShielded, + mixedOutputsUnsupported, + ambiguousShieldedSource, +} + abstract class SendViewModelBase extends WalletChangeListenerViewModel with Store { @override void onWalletChange(wallet) { @@ -125,6 +135,11 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor fiatFromSettings = _appStore.settingsStore.fiatCurrency, fiatCurrencies = FiatCurrency.all, super(appStore: _appStore) { + if (wallet.type == WalletType.pivx && + coinTypeToSpendFrom == UnspentCoinType.nonMweb) { + coinTypeToSpendFrom = UnspentCoinType.transparent; + } + outputs.add(Output(wallet, _appStore, _fiatConversationStore, _outputCryptoCurrencyHandler)); unspentCoinsListViewModel.initialSetup(); @@ -146,6 +161,20 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor } updateSendingBalance(); }); + + if (wallet.type == WalletType.pivx) { + // match source pool to destination until the user toggles: ps1 -> shielded, + // D -> transparent. toggling enables the cross paths (T->S, S->T). + reaction( + (_) => outputs.isNotEmpty ? outputs.first.address : '', + (address) { + if (_pivxSourceUserSelected) return; + coinTypeToSpendFrom = _isPivxShieldedDestination(address) + ? UnspentCoinType.sapling + : UnspentCoinType.transparent; + }, + ); + } } // Store trade and provider references for post-commit updates (e.g., Jupiter trade ID update) @@ -160,6 +189,10 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor @observable UnspentCoinType coinTypeToSpendFrom; + // pivx: true once the user hits the shielded/transparent toggle. until then + // the source follows the destination type (ps1 -> shielded, D -> transparent). + bool _pivxSourceUserSelected = false; + bool get showAddressBookPopup => _settingsStore.showAddressBookPopupEnabled; bool get isMwebEnabled => balanceViewModel.mwebEnabled; @@ -223,6 +256,95 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor } } + @action + void setPivxCoinType(UnspentCoinType type) { + if (wallet.type == WalletType.pivx) { + coinTypeToSpendFrom = type; + _pivxSourceUserSelected = true; + } + } + + static bool _isPivxShieldedDestination(String address) { + final normalized = address.trim().toLowerCase(); + return normalized.startsWith('ps1') || + normalized.startsWith('ptestsapling1'); + } + + /// Whether we are using shielded (Sapling) coins for PIVX + bool get isPivxShieldedMode => + wallet.type == WalletType.pivx && + coinTypeToSpendFrom == UnspentCoinType.sapling; + + String? get pivxUnsupportedRouteMessage { + if (wallet.type != WalletType.pivx) return null; + + final destinationAddresses = outputs + .map((output) => + output.isParsedAddress ? output.extractedAddress : output.address) + .toList(growable: false); + final routeStatus = pivxSendRouteStatusFor( + coinTypeToSpendFrom: coinTypeToSpendFrom, + destinationAddresses: destinationAddresses, + ); + + switch (routeStatus) { + case PivxSendRouteStatus.mixedOutputsUnsupported: + return 'PIVX cannot send to transparent and shielded recipients in one transaction yet.'; + case PivxSendRouteStatus.ambiguousShieldedSource: + return 'Select a PIVX transparent or shielded source before sending to a shielded address.'; + case PivxSendRouteStatus.incomplete: + case PivxSendRouteStatus.transparentToTransparent: + case PivxSendRouteStatus.shieldedToShielded: + case PivxSendRouteStatus.shieldedToTransparent: + case PivxSendRouteStatus.transparentToShielded: + return null; + } + } + + static PivxSendRouteStatus pivxSendRouteStatusFor({ + required UnspentCoinType coinTypeToSpendFrom, + required Iterable destinationAddresses, + }) { + final addresses = destinationAddresses + .map((address) => address.trim()) + .where((address) => address.isNotEmpty) + .toList(growable: false); + if (addresses.isEmpty) { + return PivxSendRouteStatus.incomplete; + } + + final hasShieldedOutput = addresses.any(_isPivxShieldedAddress); + final hasTransparentOutput = + addresses.any((address) => !_isPivxShieldedAddress(address)); + if (hasShieldedOutput && hasTransparentOutput) { + return PivxSendRouteStatus.mixedOutputsUnsupported; + } + + if (coinTypeToSpendFrom == UnspentCoinType.sapling) { + // z-to-t (deshield) is supported: shielded notes pay a transparent + // output with shielded change. + return hasTransparentOutput + ? PivxSendRouteStatus.shieldedToTransparent + : PivxSendRouteStatus.shieldedToShielded; + } + + if (hasShieldedOutput) { + // t-to-z (shield) is supported: transparent UTXOs fund the Sapling + // output with transparent change. + return coinTypeToSpendFrom == UnspentCoinType.any + ? PivxSendRouteStatus.ambiguousShieldedSource + : PivxSendRouteStatus.transparentToShielded; + } + + return PivxSendRouteStatus.transparentToTransparent; + } + + static bool _isPivxShieldedAddress(String address) { + final normalized = address.toLowerCase().trim(); + return normalized.startsWith('ps1') || + normalized.startsWith('ptestsapling1'); + } + @computed bool get isBatchSending => outputs.length > 1; @@ -356,6 +478,13 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor } else if (walletType == WalletType.litecoin && coinTypeToSpendFrom == UnspentCoinType.nonMweb) { return balanceViewModel.balances.values.first.availableBalance; + } else if (walletType == WalletType.pivx) { + final pivxBalance = balanceViewModel.balances[CryptoCurrency.pivx]; + + return pivxDisplayedBalanceForSourcePool( + coinTypeToSpendFrom: coinTypeToSpendFrom, + pivxBalance: pivxBalance, + ); } // Handle case where balance might not be available yet (e.g., during chain switch) @@ -367,6 +496,18 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor .asDisplayString(wallet.balance[selectedCryptoCurrency]!.available); } + @visibleForTesting + static String pivxDisplayedBalanceForSourcePool({ + required UnspentCoinType coinTypeToSpendFrom, + required BalanceRecord? pivxBalance, + }) { + if (coinTypeToSpendFrom == UnspentCoinType.sapling) { + return pivxBalance?.secondAvailableBalance ?? '0'; + } + + return pivxBalance?.availableBalance ?? '0'; + } + @action Future updateSendingBalance() async { // force the sendingBalance to recompute since unspent coins aren't observable @@ -380,6 +521,10 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor coinTypeToSpendFrom = UnspentCoinType.any; } else if (currentType == UnspentCoinType.mweb) { coinTypeToSpendFrom = UnspentCoinType.nonMweb; + } else if (currentType == UnspentCoinType.transparent) { + coinTypeToSpendFrom = UnspentCoinType.sapling; + } else if (currentType == UnspentCoinType.sapling) { + coinTypeToSpendFrom = UnspentCoinType.transparent; } // set it back to the original value: @@ -404,6 +549,18 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor final sendingBalance = await unspentCoinsListViewModel.getSendingBalance(coinTypeToSpendFrom); return walletTypeToCryptoCurrency(walletType).formatAmount(BigInt.from(sendingBalance)); + case WalletType.pivx: + // Shielded (sapling) reads the second/shielded balance; transparent uses UTXOs. + if (coinTypeToSpendFrom == UnspentCoinType.sapling) { + final shielded = + wallet.balance[CryptoCurrency.pivx]?.secondAvailable?.amount.toInt() ?? 0; + return walletTypeToCryptoCurrency(walletType) + .formatAmount(BigInt.from(shielded)); + } + final transparent = await unspentCoinsListViewModel + .getSendingBalance(UnspentCoinType.transparent); + return walletTypeToCryptoCurrency(walletType) + .formatAmount(BigInt.from(transparent)); default: return balance; } @@ -450,7 +607,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor WalletType.wownero, WalletType.decred, WalletType.bitcoinCash, - WalletType.dogecoin + WalletType.dogecoin, + WalletType.pivx ].contains(wallet.type) && coinTypeToSpendFrom != UnspentCoinType.lightning; @@ -462,7 +620,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, - WalletType.dogecoin + WalletType.dogecoin, + WalletType.pivx ].contains(wallet.type); @observable @@ -666,7 +825,11 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor state = FailureState(translateErrorMessage(e, walletType, currency)); } } catch (e) { - printV(e); + if (walletType == WalletType.pivx) { + printV('PIVX OpenCryptoPay transaction creation failed'); + } else { + printV(e); + } state = FailureState(translateErrorMessage(e, walletType, currency)); } return null; @@ -1121,7 +1284,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor tokenMints: tokenMints, ); } catch (e) { - printV('Error retrying balance update: $e'); + printV('Error retrying balance update'); } }); } @@ -1286,6 +1449,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor case WalletType.bitcoin: case WalletType.bitcoinCash: case WalletType.dogecoin: + case WalletType.pivx: return bitcoin!.createBitcoinTransactionCredentials( outputs, priority: priority!, @@ -1369,10 +1533,11 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor } @computed - bool get hasMemos => [WalletType.zcash].contains(wallet.type); + bool get hasMemos => [WalletType.zcash, WalletType.pivx].contains(wallet.type); final Map _maxMemoLengths = { WalletType.zcash: 512, + WalletType.pivx: 512, }; @computed diff --git a/lib/view_model/settings/other_settings_view_model.dart b/lib/view_model/settings/other_settings_view_model.dart index fa3a274ae2..c919d296ef 100644 --- a/lib/view_model/settings/other_settings_view_model.dart +++ b/lib/view_model/settings/other_settings_view_model.dart @@ -114,6 +114,7 @@ abstract class OtherSettingsViewModelBase with Store { WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin, + WalletType.pivx, ].contains(_wallet.type)) { final rate = bitcoin!.getFeeRate(_wallet, _priority); return bitcoin!.bitcoinTransactionPriorityWithLabel(_priority, rate); @@ -130,6 +131,7 @@ abstract class OtherSettingsViewModelBase with Store { WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin, + WalletType.pivx, ].contains(_wallet.type)) { final rate = bitcoin!.getFeeRate(_wallet, _priority); return bitcoin!.bitcoinTransactionPriorityWithLabel(_priority, rate, customRate: customValue); diff --git a/lib/view_model/settings/privacy_settings_view_model.dart b/lib/view_model/settings/privacy_settings_view_model.dart index 6eeabc0a68..cafa1b99b9 100644 --- a/lib/view_model/settings/privacy_settings_view_model.dart +++ b/lib/view_model/settings/privacy_settings_view_model.dart @@ -50,6 +50,7 @@ abstract class PrivacySettingsViewModelBase with Store { WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin, + WalletType.pivx, WalletType.decred ].contains(_wallet.type); diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index df9c727217..a4a6e85c1c 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -54,6 +54,22 @@ bool isLightning(TransactionInfo tx) => (tx.additionalInfo["isLightning"] as boo bool hasLightningPreimage(TransactionInfo tx) => (tx.additionalInfo["preimage"] as String?) != null; +/// Human pool route for a pivx shielded tx, disambiguating labels like the bare +/// "Shielded" (t->z). Null for receives, where the source pool is unknown to us +/// and "Received shielded" already reads clearly. +String? pivxRouteLabel(String? route) { + switch (route) { + case "t-to-z": + return "${S.current.transparent} → ${S.current.shielded}"; + case "z-to-t": + return "${S.current.shielded} → ${S.current.transparent}"; + case "z-to-z": + return "${S.current.shielded} → ${S.current.shielded}"; + default: + return null; + } +} + class TxDetailRowDefinition { TxDetailRowDefinition({ required this.keyString, @@ -205,7 +221,18 @@ class TxDetailRowDefinition { title: S.current.memo, valueGetter: (vm) => vm.transactionInfo.additionalInfo["memo"] as String, applicable: (vm) => - vm.wallet.type == WalletType.zcash && vm.transactionInfo.additionalInfo["memo"] != null, + (vm.wallet.type == WalletType.zcash || vm.wallet.type == WalletType.pivx) && + vm.transactionInfo.additionalInfo["memo"] != null, + ), + TxDetailRowDefinition( + keyString: "standard_list_item_transaction_details_pivx_route_key", + title: S.current.type, + valueGetter: (vm) => + pivxRouteLabel(vm.transactionInfo.additionalInfo["pivxRoute"] as String?) ?? "", + applicable: (vm) => + vm.wallet.type == WalletType.pivx && + vm.transactionInfo.additionalInfo["isPivxShielded"] == true && + pivxRouteLabel(vm.transactionInfo.additionalInfo["pivxRoute"] as String?) != null, ), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_asset_id_key", @@ -494,6 +521,8 @@ abstract class TransactionDetailsViewModelBase with Store { return 'https://${wallet.isTestnet ? "testnet" : "dcrdata"}.decred.org/tx/${txId.split(':')[0]}'; case WalletType.dogecoin: return "https://blockchair.com/dogecoin/transaction/${txId}"; + case WalletType.pivx: + return "https://explorer.pivx.org/#/tx/${txId}"; case WalletType.zcash: return "https://blockchair.com/zcash/transaction/${txId}"; case WalletType.none: @@ -579,7 +608,8 @@ abstract class TransactionDetailsViewModelBase with Store { ); } - if (transactionInfo.outputAddresses != null && transactionInfo.outputAddresses!.isNotEmpty) { + if (transactionInfo.outputAddresses != null && + transactionInfo.outputAddresses!.isNotEmpty) { final outputAddresses = transactionInfo.outputAddresses!.map((element) { if (element.contains("OP_RETURN:") && element.length > 40) { return "${element.substring(0, 40)}..."; diff --git a/lib/view_model/unspent_coins/unspent_coins_details_view_model.dart b/lib/view_model/unspent_coins/unspent_coins_details_view_model.dart index cbf1994017..6fc6925044 100644 --- a/lib/view_model/unspent_coins/unspent_coins_details_view_model.dart +++ b/lib/view_model/unspent_coins/unspent_coins_details_view_model.dart @@ -46,7 +46,7 @@ abstract class UnspentCoinsDetailsViewModelBase with Store { }) ]; - if ([WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin] + if ([WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin, WalletType.pivx] .contains(_type)) { items.add(BlockExplorerListItem( title: S.current.view_in_block_explorer, @@ -71,6 +71,8 @@ abstract class UnspentCoinsDetailsViewModelBase with Store { return 'https://blockchair.com/bitcoin-cash/transaction/${txId}'; case WalletType.dogecoin: return 'https://dogechain.info/tx/${txId}'; + case WalletType.pivx: + return 'https://explorer.pivx.org/#/tx/${txId}'; default: return ''; } @@ -86,6 +88,8 @@ abstract class UnspentCoinsDetailsViewModelBase with Store { return '${S.current.view_transaction_on}Blockchair.com'; case WalletType.dogecoin: return '${S.current.view_transaction_on}Dogechain.info'; + case WalletType.pivx: + return '${S.current.view_transaction_on}Chainz.cryptoid.info'; default: return ''; } diff --git a/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart b/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart index f8e351e57c..06845d4cb3 100644 --- a/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart +++ b/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart @@ -148,7 +148,7 @@ abstract class UnspentCoinsListViewModelBase with Store { if (wallet.type == WalletType.wownero) { await wownero!.updateUnspents(wallet); } - if ([WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin] + if ([WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin, WalletType.pivx] .contains(wallet.type)) { await bitcoin!.updateUnspents(wallet); } @@ -168,6 +168,7 @@ abstract class UnspentCoinsListViewModelBase with Store { case WalletType.litecoin: case WalletType.bitcoinCash: case WalletType.dogecoin: + case WalletType.pivx: return bitcoin!.getUnspents(wallet, coinTypeToSpendFrom: coinTypeToSpendFrom); case WalletType.decred: return decred!.getUnspents(wallet); @@ -186,6 +187,7 @@ abstract class UnspentCoinsListViewModelBase with Store { case WalletType.litecoin: case WalletType.bitcoinCash: case WalletType.dogecoin: + case WalletType.pivx: return bitcoin!.getUnspents(wallet, coinTypeToSpendFrom: overrideCoinTypeToSpendFrom); case WalletType.decred: return decred!.getUnspents(wallet); diff --git a/lib/view_model/wallet_address_list/address_edit_or_create_arguments.dart b/lib/view_model/wallet_address_list/address_edit_or_create_arguments.dart new file mode 100644 index 0000000000..ed0490f183 --- /dev/null +++ b/lib/view_model/wallet_address_list/address_edit_or_create_arguments.dart @@ -0,0 +1,16 @@ +import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; + +/// Arguments for the Address Edit/Create page +class AddressEditOrCreateArguments { + /// The item to edit. If null, we're creating a new address. + final WalletAddressListItem? item; + + /// If true, create a shielded address (e.g., PIVX Sapling). + /// Only relevant when creating new addresses (item == null). + final bool isShielded; + + AddressEditOrCreateArguments({ + this.item, + this.isShielded = false, + }); +} diff --git a/lib/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart index 52c5954d89..44d30f221f 100644 --- a/lib/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart @@ -5,6 +5,7 @@ import 'package:cw_core/wallet_base.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/decred/decred.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cw_core/wallet_type.dart'; part 'wallet_address_edit_or_create_view_model.g.dart'; @@ -27,7 +28,8 @@ class AddressEditOrCreateStateFailure extends AddressEditOrCreateState { } abstract class WalletAddressEditOrCreateViewModelBase with Store { - WalletAddressEditOrCreateViewModelBase({required WalletBase wallet, WalletAddressListItem? item}) + WalletAddressEditOrCreateViewModelBase( + {required WalletBase wallet, WalletAddressListItem? item, this.isShielded = false}) : isEdit = item != null, state = AddressEditOrCreateStateInitial(), label = item?.name ?? '', @@ -42,6 +44,9 @@ abstract class WalletAddressEditOrCreateViewModelBase with Store { bool isEdit; + /// If true, create a shielded address (e.g., PIVX Sapling). + final bool isShielded; + final WalletAddressListItem? _item; final WalletBase _wallet; @@ -49,7 +54,8 @@ abstract class WalletAddressEditOrCreateViewModelBase with Store { _wallet.type == WalletType.bitcoin || _wallet.type == WalletType.bitcoinCash || _wallet.type == WalletType.litecoin || - _wallet.type == WalletType.dogecoin; + _wallet.type == WalletType.dogecoin || + _wallet.type == WalletType.pivx; String get derivationPath => _item?.derivationPath ?? ''; String get index => _item?.id.toString() ?? ''; @@ -73,6 +79,13 @@ abstract class WalletAddressEditOrCreateViewModelBase with Store { Future _createNew() async { final wallet = _wallet; + // Handle PIVX shielded address creation separately + if (wallet.type == WalletType.pivx && isShielded) { + await pivx!.generateNewShieldedAddress(wallet, label: label.isNotEmpty ? label : null); + await wallet.save(); + return; + } + if (isElectrum) { await bitcoin!.generateNewAddress(wallet, label); await wallet.save(); @@ -112,6 +125,18 @@ abstract class WalletAddressEditOrCreateViewModelBase with Store { Future _update() async { final wallet = _wallet; + final item = _item; + + // Handle PIVX shielded address label update + if (wallet.type == WalletType.pivx && item != null) { + // Check if this is a shielded address (starts with 'ps1') + final address = item.address; + if (address.startsWith('ps1')) { + await pivx!.updateShieldedAddressLabel(wallet, address: address, label: label.isNotEmpty ? label : ''); + await wallet.save(); + return; + } + } if (isElectrum) await bitcoin!.updateAddress(wallet, _item!.address, label); diff --git a/lib/view_model/wallet_address_list/wallet_address_list_header.dart b/lib/view_model/wallet_address_list/wallet_address_list_header.dart index a094014952..cd3724e942 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_header.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_header.dart @@ -2,5 +2,21 @@ import 'package:cake_wallet/utils/list_item.dart'; class WalletAddressListHeader extends ListItem { final String? title; - WalletAddressListHeader({this.title}); + + /// If true, this header represents shielded addresses (e.g., PIVX Sapling). + /// Used to determine what type of address to create when user taps "Add". + final bool isShielded; + + /// Optional subtitle shown below the title (e.g., "All addresses share this balance"). + final String? subtitle; + + /// Optional balance to display in the header (for shielded pools). + final String? balance; + + WalletAddressListHeader({ + this.title, + this.isShielded = false, + this.subtitle, + this.balance, + }); } diff --git a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart index dd8b819689..5f07c41040 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart @@ -2,6 +2,7 @@ import "dart:core"; import "dart:developer" as dev; import "package:cake_wallet/bitcoin/bitcoin.dart"; +import "package:cake_wallet/pivx/pivx.dart"; import "package:cake_wallet/core/address_resolver/yat/yat_store.dart"; import "package:cake_wallet/core/amount_parsing_proxy.dart"; import "package:cake_wallet/core/fiat_conversion_service.dart"; @@ -198,9 +199,32 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo @computed bool get isFiatDisabled => _appStore.settingsStore.fiatApiMode == FiatApiMode.disabled; + static int? pivxShieldedDiversifierIndexFromAddressMap( + Map address, + ) { + final rawIndex = address['diversifierIndex']; + if (rawIndex is int) return rawIndex; + if (rawIndex is num) return rawIndex.toInt(); + if (rawIndex is String) return int.tryParse(rawIndex); + return null; + } + + static bool isPivxShieldedAddress(String address) { + final normalized = address.toLowerCase().trim(); + return normalized.startsWith('ps1') || + normalized.startsWith('ptestsapling1'); + } + @computed WalletType get type => wallet.type; + @computed + bool get isPivx => wallet.type == WalletType.pivx; + + @computed + bool get isPivxShieldedReceiveAddress => + isPivx && isPivxShieldedAddress(address.address); + @computed WalletAddressListItem get address => WalletAddressListItem(address: wallet.walletAddresses.address, isPrimary: false); @@ -290,7 +314,80 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo } if (isElectrumWallet) { - if (bitcoin!.hasSelectedSilentPayments(wallet)) { + // PIVX has special handling for shielded (Sapling) addresses + if (wallet.type == WalletType.pivx) { + final saplingEnabled = pivx?.isSaplingEnabled(wallet) ?? false; + + if (saplingEnabled) { + // touch shielded balance so MobX rebuilds the list when it changes + final walletBalance = wallet.balance[wallet.currency]; + final shieldedBalance = + walletBalance?.secondAvailable?.amount.toInt() ?? 0; + final shieldedBalanceStr = _appStore.amountParsingProxy + .getDisplayCryptoString( + shieldedBalance, walletTypeToCryptoCurrency(type)); + + // Add shielded section header with balance and explanation + addressList.add(WalletAddressListHeader( + title: S.current.shielded_sapling, + isShielded: true, + balance: '$shieldedBalanceStr ${wallet.currency.title}', + subtitle: S.current.shielded_balance_shared, + )); + + // Get default shielded address (index 0) + final defaultShieldedAddress = pivx?.getShieldedAddress(wallet); + if (defaultShieldedAddress != null) { + addressList.add(WalletAddressListItem( + id: 0, + isPrimary: true, + name: S.current.primary_receive_address, + address: defaultShieldedAddress, + // Don't show per-address balance - it's in the header + balance: null, + )); + } + + // Get additional shielded addresses (user-created diversified addresses) + final shieldedAddresses = pivx?.getShieldedAddresses(wallet) ?? []; + for (var i = 0; i < shieldedAddresses.length; i++) { + final addr = shieldedAddresses[i]; + final divIndex = pivxShieldedDiversifierIndexFromAddressMap(addr); + if (divIndex == null) continue; + final divIndexText = divIndex.toString(); + final label = addr['label'] as String?; + addressList.add(WalletAddressListItem( + id: divIndex, + isPrimary: false, + // Use label if set, otherwise localized "Receive Address #N" + name: label ?? + S.current.receive_address_n.replaceAll('#{0}', divIndexText), + address: addr['address'] as String, + // No per-address balance - shielded pool is unified + balance: null, + )); + } + + addressList.add(WalletAddressListHeader( + title: S.current.transparent, isShielded: false)); + } + + // Then add transparent addresses + var addressItems = bitcoin!.getSubAddresses(wallet).map((subaddress) { + final isPrimary = subaddress.id == 0; + return WalletAddressListItem( + id: subaddress.id, + isPrimary: + !saplingEnabled && isPrimary, // Only primary if no shielded + name: subaddress.name, + address: subaddress.address, + txCount: subaddress.txCount, + balance: _appStore.amountParsingProxy.getDisplayCryptoString( + subaddress.balance, walletTypeToCryptoCurrency(type)), + isChange: subaddress.isChange); + }); + addressList.addAll(addressItems); + } else if (bitcoin!.hasSelectedSilentPayments(wallet)) { final addressItems = bitcoin!.getSilentPaymentAddresses(wallet).map((address) { final isPrimary = address.id == 0; @@ -506,12 +603,16 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo WalletType.decred, WalletType.dogecoin, WalletType.zcash, + WalletType.pivx, ].contains(wallet.type) && !isLightning && isZCashTransparent; @computed - bool get hasAddressRotation => hasAddressList && wallet.type != WalletType.zcash; + bool get hasAddressRotation => + hasAddressList && + wallet.type != WalletType.zcash && + !isPivxShieldedReceiveAddress; @computed bool get isElectrumWallet => [ @@ -519,6 +620,7 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin, + WalletType.pivx, ].contains(wallet.type); List getWalletImages(int? chainId) { @@ -645,7 +747,7 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo @computed bool get showAddManualAddresses => !isAutoGenerateSubaddressEnabled || - [WalletType.monero, WalletType.wownero].contains(wallet.type); + [WalletType.monero, WalletType.wownero, WalletType.pivx].contains(wallet.type); List _baseItems; @@ -681,6 +783,9 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo if (wallet.type == WalletType.zcash) { await zcash!.setAddressType(wallet, option); } + if (wallet.type == WalletType.pivx) { + await pivx!.setAddressType(wallet, option); + } } void _init() { diff --git a/lib/view_model/wallet_keys_view_model.dart b/lib/view_model/wallet_keys_view_model.dart index ce10a269b1..07f229e4d6 100644 --- a/lib/view_model/wallet_keys_view_model.dart +++ b/lib/view_model/wallet_keys_view_model.dart @@ -84,6 +84,8 @@ abstract class WalletKeysViewModelBase with Store { // this is incomplete, needs legacy seed toggle for XMR bool get shouldShowHeightBox => [WalletType.bitcoin, WalletType.zcash].contains(_wallet.type); + + bool get isPivx => _wallet.type == WalletType.pivx; final ObservableList items; final ObservableList silentPaymentItems; @@ -232,6 +234,10 @@ abstract class WalletKeysViewModelBase with Store { StandartListItem(title: "xPub", value: electrumKeys['xpub']!), ]); break; + case WalletType.pivx: + // PIVX recovery is intentionally seed-only until WIF/viewing-key + // import/export policy is complete and manually verified. + break; case WalletType.none: case WalletType.haven: break; @@ -335,6 +341,8 @@ abstract class WalletKeysViewModelBase with Store { return 'dogecoin-wallet'; case WalletType.zcash: return 'zcash-wallet'; + case WalletType.pivx: + return 'pivx-wallet'; case WalletType.none: throw Exception('Unexpected wallet type: ${_wallet.type.toString()} for wallet keys'); } diff --git a/lib/view_model/wallet_new_vm.dart b/lib/view_model/wallet_new_vm.dart index 4d50370f2c..dff2271f18 100644 --- a/lib/view_model/wallet_new_vm.dart +++ b/lib/view_model/wallet_new_vm.dart @@ -1,6 +1,7 @@ import 'package:cake_wallet/core/new_wallet_arguments.dart'; import 'package:cake_wallet/dogecoin/dogecoin.dart'; import 'package:cake_wallet/evm/evm.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cake_wallet/zano/zano.dart'; import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; import 'package:cake_wallet/solana/solana.dart'; @@ -103,6 +104,13 @@ abstract class WalletNewVMBase extends WalletCreationVM with Store { passphrase: passphrase, mnemonic: newWalletArguments!.mnemonic, ); + case WalletType.pivx: + return pivx!.createPivxNewWalletCredentials( + name: name, + password: walletPassword, + passphrase: passphrase, + mnemonic: newWalletArguments!.mnemonic, + ); case WalletType.nano: case WalletType.banano: return nano!.createNanoNewWalletCredentials( diff --git a/lib/view_model/wallet_restore_view_model.dart b/lib/view_model/wallet_restore_view_model.dart index 350b213377..01007c8733 100644 --- a/lib/view_model/wallet_restore_view_model.dart +++ b/lib/view_model/wallet_restore_view_model.dart @@ -7,6 +7,7 @@ import 'package:cake_wallet/dogecoin/dogecoin.dart'; import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/nano/nano.dart'; +import 'package:cake_wallet/pivx/pivx.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/store/app_store.dart'; @@ -63,6 +64,7 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { case WalletType.bitcoinCash: case WalletType.zano: case WalletType.dogecoin: + case WalletType.pivx: availableModes = [WalletRestoreMode.seed]; break; case WalletType.none: @@ -89,6 +91,7 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { WalletType.wownero, WalletType.zcash, WalletType.zano, + WalletType.pivx, ].contains(type); late final bool hasRestoreFromPrivateKey = [ @@ -166,6 +169,14 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { password: password, passphrase: passphrase, ); + case WalletType.pivx: + return pivx!.createPivxRestoreWalletFromSeedCredentials( + name: name, + mnemonic: seed, + password: password, + passphrase: passphrase, + height: height, + ); case WalletType.nano: case WalletType.banano: return nano!.createNanoRestoreWalletFromSeedCredentials( diff --git a/model_generator.sh b/model_generator.sh index 06df837fd5..ec989c5bc4 100755 --- a/model_generator.sh +++ b/model_generator.sh @@ -3,7 +3,7 @@ set -x -e pids=() -for cwcoin in cw_{core,evm,monero,bitcoin,nano,bitcoin_cash,solana,tron,wownero,zano,decred,dogecoin,zcash} +for cwcoin in cw_{core,evm,monero,bitcoin,nano,bitcoin_cash,solana,tron,wownero,zano,decred,dogecoin,zcash,pivx} do if [[ "x$1" == "xasync" ]]; then diff --git a/pubspec_base.yaml b/pubspec_base.yaml index 52a41ad6f2..51c081dae0 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -283,6 +283,7 @@ flutter: - assets/zano_node_list.yml - assets/decred_node_list.yml - assets/dogecoin_electrum_server_list.yml + - assets/pivx_electrum_server_list.yml - assets/base_node_list.yml - assets/arbitrum_node_list.yml - assets/zcash_node_list.yml diff --git a/pubspec_overrides.yaml b/pubspec_overrides.yaml index 2bf9d2ce11..add5ba81b7 100644 --- a/pubspec_overrides.yaml +++ b/pubspec_overrides.yaml @@ -87,8 +87,8 @@ dependency_overrides: ref: 57b78afb85bd2c30d3cdb9f7884f3878a62be442 bitcoin_base: git: - url: https://github.com/cake-tech/bitcoin_base - ref: 4e41f96f4838139895c65c3f49109d05af5d46aa + url: https://github.com/Liquid369/bitcoin_base + ref: 490c1b644090685a49b2952b2b1205f0b4c50e84 bloc: git: url: https://github.com/felangel/bloc diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 574340b1e6..80bf344eb4 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -287,6 +287,7 @@ "copied_to_clipboard": "Copied to Clipboard", "copy": "Copy", "copy_address": "Copy Address", + "copy_payment_uri": "Copy Payment URI", "copy_id": "Copy ID", "copy_payjoin_address": "Copy Payjoin Address", "copy_payjoin_url": "Copy Payjoin URL", @@ -701,6 +702,13 @@ "mweb_help_disclaimer": "You can easily move coins between the two layers by masking and unmasking", "mweb_unconfirmed": "Unconfirmed MWEB", "name": "Name", + "shielded": "Shielded", + "shielded_unconfirmed": "Shielded (unconfirmed)", + "shielded_sapling": "Shielded (Sapling)", + "shielded_balance_shared": "All addresses share this balance", + "primary_receive_address": "Primary Receive Address", + "receive_address_n": "Receive Address #{0}", + "transparent": "Transparent", "nano_current_rep": "Current Representative", "nano_gpt_thanks_message": "Thanks for using NanoGPT! Remember to head back to the browser after your transaction completes!", "nano_pick_new_rep": "Pick a new representative", @@ -865,6 +873,10 @@ "privacy_settings": "Privacy settings", "private_key": "Private key", "private_memo_optional": "Private Memo (optional)", + "pivx_seed_only_recovery_notice": "PIVX recovery is seed-only in Cake Wallet. Keep your seed phrase and restore height safe. WIF, viewing-key, and shielded key restore are not supported for PIVX yet. Encrypted Cake backups may include PIVX shielded wallet state, but they do not replace your seed phrase.", + "pivx_payment_uri_unsupported_parameters": "Unsupported PIVX payment URI fields were ignored.", + "pivx_shielded_receive_disclaimer": "PIVX shielded receive addresses are privacy-sensitive. Cake Wallet uses the sensitive clipboard on supported mobile platforms. Payment URI memos are not supported yet.", + "pivx_memo_disclaimer": "Memos are only delivered to shielded recipients. Sending to a transparent address drops the memo.", "proceed_after_one_minute": "If the screen doesn’t proceed after 1 minute, check your email.", "proceed": "Proceed", "proceed_on_device": "Proceed on your device", diff --git a/scripts/android/build_pivx.sh b/scripts/android/build_pivx.sh new file mode 100755 index 0000000000..0a59fc542c --- /dev/null +++ b/scripts/android/build_pivx.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -x -e +cd "$(dirname "$0")" + +# PIVX Sapling Rust sources live in-tree at cw_pivx/rust, so unlike Zcash +# there is nothing to clone or prepare; build the library straight from source. +../../cw_pivx/scripts/build_android.sh diff --git a/scripts/android/pubspec_gen.sh b/scripts/android/pubspec_gen.sh index 9cf8976d62..78ba543b2f 100755 --- a/scripts/android/pubspec_gen.sh +++ b/scripts/android/pubspec_gen.sh @@ -10,7 +10,7 @@ case $APP_ANDROID_TYPE in CONFIG_ARGS="--monero" ;; $CAKEWALLET) - CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron --wownero --zano --decred --dogecoin --base --zcash --arbitrum --bsc" + CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron --wownero --zano --decred --dogecoin --base --zcash --arbitrum --bsc --pivx" ;; esac diff --git a/scripts/ios/app_config.sh b/scripts/ios/app_config.sh index 70577e4dab..cd362c4d1b 100755 --- a/scripts/ios/app_config.sh +++ b/scripts/ios/app_config.sh @@ -31,7 +31,7 @@ case $APP_IOS_TYPE in ;; $CAKEWALLET) - CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron --wownero --zano --decred --dogecoin --base --zcash --arbitrum --bsc" + CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron --wownero --zano --decred --dogecoin --base --zcash --arbitrum --bsc --pivx" ;; esac diff --git a/scripts/linux/app_config.sh b/scripts/linux/app_config.sh index 050bee6c76..4664fe9b8a 100755 --- a/scripts/linux/app_config.sh +++ b/scripts/linux/app_config.sh @@ -13,7 +13,7 @@ CONFIG_ARGS="" case $APP_LINUX_TYPE in $CAKEWALLET) - CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron --wownero --dogecoin --base --arbitrum --bsc --excludeFlutterSecureStorage";; + CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron --wownero --dogecoin --base --arbitrum --bsc --pivx --excludeFlutterSecureStorage";; esac cp -rf pubspec_description.yaml pubspec.yaml diff --git a/scripts/macos/app_config.sh b/scripts/macos/app_config.sh index 64ae2dd965..a3c3ecf46a 100755 --- a/scripts/macos/app_config.sh +++ b/scripts/macos/app_config.sh @@ -36,7 +36,7 @@ case $APP_MACOS_TYPE in $MONERO_COM) CONFIG_ARGS="--monero";; $CAKEWALLET) - CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron --wownero --dogecoin --base --arbitrum --bsc";; + CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron --wownero --dogecoin --base --arbitrum --bsc --pivx";; esac cp -rf pubspec_description.yaml pubspec.yaml diff --git a/scripts/prepare_pivx_params.sh b/scripts/prepare_pivx_params.sh new file mode 100755 index 0000000000..c9d9fc0b63 --- /dev/null +++ b/scripts/prepare_pivx_params.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Fetch the PIVX Sapling proving parameters into cw_pivx/assets/params/ so they +# get bundled into the app. Idempotent: skips download when the file already +# exists with the correct SHA256. CI / fresh clones run this before building. +# +# The params are the universal Sapling params (byte-identical to Zcash's). +set -euo pipefail + +# Repo root = parent of this script's dir. +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEST_DIR="$ROOT_DIR/cw_pivx/assets/params" + +# name url sha256 +PARAMS=( + "sapling-spend.params|https://duddino.com/sapling-spend.params|8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" + "sapling-output.params|https://duddino.com/sapling-output.params|2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" +) + +# sha256 of a file, portable across macOS (shasum) and Linux (sha256sum). +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +mkdir -p "$DEST_DIR" + +for entry in "${PARAMS[@]}"; do + IFS='|' read -r name url want <<<"$entry" + dest="$DEST_DIR/$name" + + if [[ -f "$dest" ]] && [[ "$(sha256_of "$dest")" == "$want" ]]; then + echo "OK (cached): $name" + continue + fi + + echo "Downloading $name ..." + curl -fL --retry 3 -o "$dest.tmp" "$url" + + got="$(sha256_of "$dest.tmp")" + if [[ "$got" != "$want" ]]; then + rm -f "$dest.tmp" + echo "ERROR: SHA256 mismatch for $name" >&2 + echo " expected $want" >&2 + echo " got $got" >&2 + exit 1 + fi + + mv "$dest.tmp" "$dest" + echo "OK: $name" +done + +echo "PIVX Sapling params ready in $DEST_DIR" diff --git a/test/src/screens/dashboard/pages/balance/balance_row_widget_test.dart b/test/src/screens/dashboard/pages/balance/balance_row_widget_test.dart new file mode 100644 index 0000000000..ee5a027bc1 --- /dev/null +++ b/test/src/screens/dashboard/pages/balance/balance_row_widget_test.dart @@ -0,0 +1,39 @@ +import 'package:cake_wallet/src/screens/dashboard/pages/balance/balance_row_widget.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('BalanceRowWidget MWEB controls', () { + test('shows MWEB controls only for Litecoin LTC balances', () { + expect( + BalanceRowWidget.shouldShowLitecoinMwebControls( + walletType: WalletType.litecoin, + currency: CryptoCurrency.ltc, + ), + isTrue, + ); + }); + + test('does not show Litecoin MWEB controls for PIVX shielded rows', () { + expect( + BalanceRowWidget.shouldShowLitecoinMwebControls( + walletType: WalletType.pivx, + currency: CryptoCurrency.pivx, + ), + isFalse, + ); + }); + + test('does not show MWEB controls for non-Litecoin wallets using LTC asset', + () { + expect( + BalanceRowWidget.shouldShowLitecoinMwebControls( + walletType: WalletType.pivx, + currency: CryptoCurrency.ltc, + ), + isFalse, + ); + }); + }); +} diff --git a/test/utils/payment_request_test.dart b/test/utils/payment_request_test.dart index b825b0a68b..1a79c77074 100644 --- a/test/utils/payment_request_test.dart +++ b/test/utils/payment_request_test.dart @@ -120,5 +120,40 @@ void main() { expect(paymentRequest.resolveTokenAmount(usdt), null); }); }); + + group('PIVX URIs', () { + test('extract address and amount while leaving message as a local note', + () { + final uri = + Uri.parse('pivx:ps1receiveaddress?amount=1.23&message=invoice'); + final paymentRequest = PaymentRequest.fromUri(uri); + + expect(paymentRequest.scheme, 'pivx'); + expect(paymentRequest.address, 'ps1receiveaddress'); + expect(paymentRequest.amount, '1.23'); + expect(paymentRequest.note, 'invoice'); + expect(paymentRequest.hasUnsupportedParameters, false); + }); + + test('marks memo fields unsupported instead of treating them as notes', + () { + final uri = Uri.parse( + 'pivx:ps1receiveaddress?amount=1.23&memo=secret&req-memo=secret'); + final paymentRequest = PaymentRequest.fromUri(uri); + + expect(paymentRequest.note, ''); + expect(paymentRequest.hasUnsupportedParameters, true); + expect(paymentRequest.unsupportedParameters, + containsAll(['memo', 'req-memo'])); + }); + + test('marks unknown required PIVX URI fields unsupported', () { + final uri = Uri.parse('pivx:ps1receiveaddress?req-pool=sapling'); + final paymentRequest = PaymentRequest.fromUri(uri); + + expect(paymentRequest.hasUnsupportedParameters, true); + expect(paymentRequest.unsupportedParameters, contains('req-pool')); + }); + }); }); } diff --git a/test/view_model/pivx_send_view_model_test.dart b/test/view_model/pivx_send_view_model_test.dart new file mode 100644 index 0000000000..71711a86e0 --- /dev/null +++ b/test/view_model/pivx_send_view_model_test.dart @@ -0,0 +1,162 @@ +import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; +import 'package:cake_wallet/view_model/send/send_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/unspent_coin_type.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PIVX send displayed balance fallback', () { + test('uses transparent available balance outside Sapling mode', () { + final balance = _pivxBalance( + availableBalance: '12.345', + secondAvailableBalance: '6.789', + ); + + expect( + SendViewModelBase.pivxDisplayedBalanceForSourcePool( + coinTypeToSpendFrom: UnspentCoinType.transparent, + pivxBalance: balance, + ), + '12.345', + ); + expect( + SendViewModelBase.pivxDisplayedBalanceForSourcePool( + coinTypeToSpendFrom: UnspentCoinType.any, + pivxBalance: balance, + ), + '12.345', + ); + }); + + test('uses shielded second-available balance in Sapling mode', () { + final balance = _pivxBalance( + availableBalance: '12.345', + secondAvailableBalance: '6.789', + ); + + expect( + SendViewModelBase.pivxDisplayedBalanceForSourcePool( + coinTypeToSpendFrom: UnspentCoinType.sapling, + pivxBalance: balance, + ), + '6.789', + ); + }); + + test('falls back to zero when PIVX balance is unavailable', () { + expect( + SendViewModelBase.pivxDisplayedBalanceForSourcePool( + coinTypeToSpendFrom: UnspentCoinType.transparent, + pivxBalance: null, + ), + '0', + ); + expect( + SendViewModelBase.pivxDisplayedBalanceForSourcePool( + coinTypeToSpendFrom: UnspentCoinType.sapling, + pivxBalance: null, + ), + '0', + ); + }); + }); + + group('PIVX send route matrix', () { + test('leaves empty destinations incomplete', () { + expect( + SendViewModelBase.pivxSendRouteStatusFor( + coinTypeToSpendFrom: UnspentCoinType.transparent, + destinationAddresses: [''], + ), + PivxSendRouteStatus.incomplete, + ); + }); + + test('allows transparent-to-transparent sends', () { + expect( + SendViewModelBase.pivxSendRouteStatusFor( + coinTypeToSpendFrom: UnspentCoinType.transparent, + destinationAddresses: ['DTransparentAddress'], + ), + PivxSendRouteStatus.transparentToTransparent, + ); + }); + + test('allows shielded-to-shielded sends', () { + expect( + SendViewModelBase.pivxSendRouteStatusFor( + coinTypeToSpendFrom: UnspentCoinType.sapling, + destinationAddresses: ['ps1shieldedaddress'], + ), + PivxSendRouteStatus.shieldedToShielded, + ); + expect( + SendViewModelBase.pivxSendRouteStatusFor( + coinTypeToSpendFrom: UnspentCoinType.sapling, + destinationAddresses: ['ptestsapling1shieldedaddress'], + ), + PivxSendRouteStatus.shieldedToShielded, + ); + }); + + test('allows transparent-to-shielded shield route', () { + expect( + SendViewModelBase.pivxSendRouteStatusFor( + coinTypeToSpendFrom: UnspentCoinType.transparent, + destinationAddresses: ['ps1shieldedaddress'], + ), + PivxSendRouteStatus.transparentToShielded, + ); + }); + + test('allows shielded-to-transparent deshield route', () { + expect( + SendViewModelBase.pivxSendRouteStatusFor( + coinTypeToSpendFrom: UnspentCoinType.sapling, + destinationAddresses: ['DTransparentAddress'], + ), + PivxSendRouteStatus.shieldedToTransparent, + ); + }); + + test('blocks mixed transparent and shielded outputs', () { + expect( + SendViewModelBase.pivxSendRouteStatusFor( + coinTypeToSpendFrom: UnspentCoinType.sapling, + destinationAddresses: ['ps1shieldedaddress', 'DTransparentAddress'], + ), + PivxSendRouteStatus.mixedOutputsUnsupported, + ); + }); + + test('blocks ambiguous any-source shielded sends', () { + expect( + SendViewModelBase.pivxSendRouteStatusFor( + coinTypeToSpendFrom: UnspentCoinType.any, + destinationAddresses: ['ps1shieldedaddress'], + ), + PivxSendRouteStatus.ambiguousShieldedSource, + ); + }); + }); +} + +BalanceRecord _pivxBalance({ + required String availableBalance, + required String secondAvailableBalance, +}) { + return BalanceRecord( + availableBalance: availableBalance, + additionalBalance: '0', + secondAvailableBalance: secondAvailableBalance, + secondAdditionalBalance: '0', + frozenBalance: '0', + fiatAvailableBalance: '0.00', + fiatAdditionalBalance: '0.00', + fiatFrozenBalance: '0.00', + fiatSecondAvailableBalance: '0.00', + fiatSecondAdditionalBalance: '0.00', + asset: CryptoCurrency.pivx, + formattedAssetTitle: 'PIVX', + ); +} diff --git a/test/view_model/pivx_transaction_details_view_model_test.dart b/test/view_model/pivx_transaction_details_view_model_test.dart new file mode 100644 index 0000000000..80a0758329 --- /dev/null +++ b/test/view_model/pivx_transaction_details_view_model_test.dart @@ -0,0 +1,133 @@ +import 'package:cake_wallet/view_model/transaction_details_view_model.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:cw_core/transaction_info.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PIVX shielded transaction detail formatting', () { + test('labels pending receive with confirmation progress', () { + final tx = _FakeTransactionInfo( + direction: TransactionDirection.incoming, + confirmations: 2, + isPending: true, + ); + + expect( + TransactionDetailsViewModelBase.pivxShieldedConfirmationState(tx, 6), + 'Pending shielded receive (2/6)', + ); + }); + + test('labels confirmed receive as spendable', () { + final tx = _FakeTransactionInfo( + direction: TransactionDirection.incoming, + confirmations: 6, + isPending: false, + ); + + expect( + TransactionDetailsViewModelBase.pivxShieldedConfirmationState(tx, 6), + 'Spendable shielded receive', + ); + }); + + test('labels pending outgoing broadcast before mined spend', () { + final tx = _FakeTransactionInfo( + direction: TransactionDirection.outgoing, + confirmations: 0, + isPending: true, + ); + + expect( + TransactionDetailsViewModelBase.pivxShieldedConfirmationState(tx, 6), + 'Broadcast, waiting for mined shielded spend (0/6)', + ); + }); + + test('labels mined outgoing shielded send before required confirmations', () { + final tx = _FakeTransactionInfo( + direction: TransactionDirection.outgoing, + confirmations: 3, + isPending: false, + ); + + expect( + TransactionDetailsViewModelBase.pivxShieldedConfirmationState(tx, 6), + 'Pending shielded send (3/6)', + ); + }); + + test('labels confirmed outgoing shielded send', () { + final tx = _FakeTransactionInfo( + direction: TransactionDirection.outgoing, + confirmations: 7, + isPending: false, + ); + + expect( + TransactionDetailsViewModelBase.pivxShieldedConfirmationState(tx, 6), + 'Confirmed shielded send', + ); + }); + + test('clamps negative confirmation progress to zero', () { + final tx = _FakeTransactionInfo( + direction: TransactionDirection.incoming, + confirmations: -3, + isPending: true, + ); + + expect( + TransactionDetailsViewModelBase.pivxShieldedConfirmationState(tx, 6), + 'Pending shielded receive (0/6)', + ); + }); + + test('formats PIVX pools and routes for details rows', () { + expect(TransactionDetailsViewModelBase.formatPivxPool('shielded'), + 'Shielded'); + expect(TransactionDetailsViewModelBase.formatPivxPool('transparent'), + 'Transparent'); + expect(TransactionDetailsViewModelBase.formatPivxRoute('z-receive'), + 'Shielded receive'); + expect(TransactionDetailsViewModelBase.formatPivxRoute('z-to-z'), + 'Shielded to shielded'); + expect(TransactionDetailsViewModelBase.formatPivxRoute('t-to-t'), + 'Transparent to transparent'); + expect(TransactionDetailsViewModelBase.formatPivxRoute('t-to-z'), + 'Shielding'); + expect(TransactionDetailsViewModelBase.formatPivxRoute('z-to-t'), + 'Deshielding'); + }); + }); +} + +class _FakeTransactionInfo extends TransactionInfo { + _FakeTransactionInfo({ + required TransactionDirection direction, + required int confirmations, + required bool isPending, + }) { + id = 'pivx-shielded-tx'; + txHash = id; + amount = 123; + fee = 1; + this.direction = direction; + this.confirmations = confirmations; + this.isPending = isPending; + date = DateTime.fromMillisecondsSinceEpoch(0); + height = confirmations > 0 ? 100 : 0; + } + + @override + String amountFormatted() => '0.00000123 PIVX'; + + @override + void changeFiatAmount(String amount) {} + + @override + String feeFormatted() => '0.00000001 PIVX'; + + @override + String fiatAmount() => '0.00'; +} diff --git a/test/view_model/pivx_wallet_address_list_view_model_test.dart b/test/view_model/pivx_wallet_address_list_view_model_test.dart new file mode 100644 index 0000000000..c2043755c7 --- /dev/null +++ b/test/view_model/pivx_wallet_address_list_view_model_test.dart @@ -0,0 +1,41 @@ +import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PIVX shielded address list', () { + test('accepts int diversifier indexes from wallet storage', () { + expect( + WalletAddressListViewModelBase + .pivxShieldedDiversifierIndexFromAddressMap({ + 'diversifierIndex': 7, + }), + equals(7), + ); + }); + + test('accepts legacy string diversifier indexes', () { + expect( + WalletAddressListViewModelBase + .pivxShieldedDiversifierIndexFromAddressMap({ + 'diversifierIndex': '8', + }), + equals(8), + ); + }); + + test('skips malformed diversifier indexes without crashing', () { + expect( + WalletAddressListViewModelBase + .pivxShieldedDiversifierIndexFromAddressMap({ + 'diversifierIndex': 'not-an-index', + }), + isNull, + ); + expect( + WalletAddressListViewModelBase + .pivxShieldedDiversifierIndexFromAddressMap({}), + isNull, + ); + }); + }); +} diff --git a/tool/configure.dart b/tool/configure.dart index c11d020e10..62c7047b5a 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -12,6 +12,7 @@ const decredOutputPath = 'lib/decred/decred.dart'; const dogecoinOutputPath = 'lib/dogecoin/dogecoin.dart'; const evmOutputPath = 'lib/evm/evm.dart'; const zcashOutputPath = 'lib/zcash/zcash.dart'; +const pivxOutputPath = 'lib/pivx/pivx.dart'; const walletTypesPath = 'lib/wallet_types.g.dart'; const secureStoragePath = 'lib/core/secure_storage.dart'; const pubspecDefaultPath = 'pubspec_default.yaml'; @@ -32,6 +33,7 @@ Future main(List args) async { final hasZano = args.contains('${prefix}zano'); final hasDecred = args.contains('${prefix}decred'); final hasDogecoin = args.contains('${prefix}dogecoin'); + final hasPivx = args.contains('${prefix}pivx'); final hasBase = args.contains('${prefix}base'); final hasArbitrum = args.contains('${prefix}arbitrum'); final hasBsc = args.contains('${prefix}bsc'); @@ -52,6 +54,7 @@ Future main(List args) async { await generateDogecoin(hasDogecoin); await generateEVM(hasEVM); await generateZcash(hasZcash); + await generatePivx(hasPivx); await generatePubspec( hasMonero: hasMonero, @@ -68,6 +71,7 @@ Future main(List args) async { hasZano: hasZano, hasDecred: hasDecred, hasDogecoin: hasDogecoin, + hasPivx: hasPivx, hasBase: hasBase, hasArbitrum: hasArbitrum, hasBsc: hasBsc, @@ -87,6 +91,7 @@ Future main(List args) async { hasZano: hasZano, hasDecred: hasDecred, hasDogecoin: hasDogecoin, + hasPivx: hasPivx, hasBase: hasBase, hasArbitrum: hasArbitrum, hasBsc: hasBsc, @@ -1392,6 +1397,109 @@ abstract class DogeCoin { await outputFile.writeAsString(output); } +Future generatePivx(bool hasImplementation) async { + final outputFile = File(pivxOutputPath); + const pivxCommonHeaders = """ +import 'package:cw_core/receive_page_option.dart'; +import 'package:cw_core/transaction_priority.dart'; +import 'package:cw_core/unspent_coins_info.dart'; +import 'package:cw_core/wallet_credentials.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_service.dart'; +import 'package:hive/hive.dart'; +"""; + const pivxCWHeaders = """ +import 'package:cw_pivx/cw_pivx.dart'; +"""; + const pivxCwPart = "part 'cw_pivx.dart';"; + const pivxContent = """ +abstract class Pivx { + WalletService createPivxWalletService( + Box unspentCoinSource, bool isDirect); + + WalletCredentials createPivxNewWalletCredentials( + {required String name, + WalletInfo? walletInfo, + String? password, + String? passphrase, + String? mnemonic}); + + WalletCredentials createPivxRestoreWalletFromSeedCredentials( + {required String name, + required String mnemonic, + required String password, + String? passphrase, + int? height}); + + TransactionPriority deserializePivxTransactionPriority(int raw); + + TransactionPriority getDefaultTransactionPriority(); + + List getTransactionPriorities(); + + TransactionPriority getPivxTransactionPrioritySlow(); + + /// Estimate a restore block height for [date] (PIVX ~60s blocks). + int getHeightByDate({required DateTime date}); + + // Sapling/shielded address methods + Future generateNewShieldedAddress(Object wallet, {String? label}); + + Future updateShieldedAddressLabel(Object wallet, + {required String address, required String label}); + + bool isSaplingEnabled(Object wallet); + + String getShieldedAddress(Object wallet); + + int getShieldedBalance(Object wallet); + + bool isShieldSyncing(Object wallet); + + bool isSaplingRpcAvailable(Object wallet); + + int getLastShieldSyncedBlock(Object wallet); + + String? getLastShieldSyncError(Object wallet); + + List> getShieldedAddresses(Object wallet); + + // Receive page transparent/shielded switching + List getPivxReceivePageOptions(Object wallet); + + ReceivePageOption getSelectedAddressType(Object wallet); + + bool isPivxReceivePageOption(ReceivePageOption option); + + dynamic getOptionToType(ReceivePageOption option); + + Future setAddressType(Object wallet, dynamic option); + + /// Probe whether the ElectrumX node at [uri] serves the PIVX v1 Sapling + /// contract, so node switching won't move a PIVX wallet onto a node that + /// can't do shielded sync. + Future checkNodeSupportsSapling( + {required Uri uri, bool? useSSL, required bool isTestnet}); +} +"""; + + const pivxEmptyDefinition = 'Pivx? pivx;\n'; + const pivxCWDefinition = 'Pivx? pivx = CWPivx();\n'; + + final output = '$pivxCommonHeaders\n' + + (hasImplementation ? '$pivxCWHeaders\n' : '\n') + + (hasImplementation ? '$pivxCwPart\n\n' : '\n') + + (hasImplementation ? pivxCWDefinition : pivxEmptyDefinition) + + '\n' + + pivxContent; + + if (outputFile.existsSync()) { + await outputFile.delete(); + } + + await outputFile.writeAsString(output); +} + Future generateEVM(bool hasImplementation) async { final outputFile = File(evmOutputPath); const evmCommonHeaders = """ @@ -1848,6 +1956,7 @@ Future generatePubspec({ required bool hasZano, required bool hasDecred, required bool hasDogecoin, + required bool hasPivx, required bool hasBase, required bool hasArbitrum, required bool hasBsc, @@ -1916,6 +2025,10 @@ Future generatePubspec({ cw_zcash: path: ./cw_zcash """; + const cwPivx = """ + cw_pivx: + path: ./cw_pivx + """; final inputFile = File(pubspecOutputPath); final inputText = await inputFile.readAsString(); @@ -1982,6 +2095,10 @@ Future generatePubspec({ output += '\n$cwZcash'; } + if (hasPivx) { + output += '\n$cwPivx'; + } + final outputLines = output.split('\n'); inputLines.insertAll(dependenciesIndex + 1, outputLines); final outputContent = inputLines.join('\n'); @@ -2008,6 +2125,7 @@ Future generateWalletTypes({ required bool hasZano, required bool hasDecred, required bool hasDogecoin, + required bool hasPivx, required bool hasBase, required bool hasArbitrum, required bool hasBsc, @@ -2059,6 +2177,10 @@ Future generateWalletTypes({ outputContent += '\tWalletType.bitcoinCash,\n'; } + if (hasPivx) { + outputContent += '\tWalletType.pivx,\n'; + } + if (hasBitcoin) { outputContent += '\tWalletType.litecoin,\n'; }