From 59af37afd2c7891d8c0b80329470e8d06bbe8d75 Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 01/11] feat(core): register PIVX wallet type, currency, and node --- .github/workflows/reusable-build.yml | 7 +++ .gitignore | 1 + cw_core/lib/crypto_currency.dart | 7 ++- cw_core/lib/currency_for_wallet_type.dart | 3 ++ cw_core/lib/node.dart | 42 ++++++++++++++++- cw_core/lib/node_legacy.dart | 1 + cw_core/lib/node_list.dart | 3 ++ cw_core/lib/payment_uris.dart | 15 ++++++ cw_core/lib/unspent_coin_type.dart | 20 +++++++- cw_core/lib/wallet_type.dart | 14 ++++++ cw_core/lib/wallet_type.part.dart | 5 ++ scripts/android/build_pivx.sh | 7 +++ scripts/prepare_pivx_params.sh | 56 +++++++++++++++++++++++ 13 files changed, 178 insertions(+), 3 deletions(-) create mode 100755 scripts/android/build_pivx.sh create mode 100755 scripts/prepare_pivx_params.sh 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/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/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/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" From 0bb45d40a405fa17277fbde43f6b785854d74199 Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 02/11] feat(pivx): add package scaffold and transparent wallet --- cw_pivx/.gitignore | 38 + cw_pivx/CHANGELOG.md | 7 + cw_pivx/LICENSE | 21 + cw_pivx/analysis_options.yaml | 4 + cw_pivx/lib/cw_pivx.dart | 8 + cw_pivx/lib/src/pivx_network.dart | 261 ++ .../lib/src/pivx_receive_page_options.dart | 68 + .../lib/src/pivx_transaction_priority.dart | 70 + cw_pivx/lib/src/pivx_wallet.dart | 2908 +++++++++++++++++ cw_pivx/lib/src/pivx_wallet_addresses.dart | 89 + .../src/pivx_wallet_creation_credentials.dart | 53 + cw_pivx/lib/src/pivx_wallet_service.dart | 176 + 12 files changed, 3703 insertions(+) create mode 100644 cw_pivx/.gitignore create mode 100644 cw_pivx/CHANGELOG.md create mode 100644 cw_pivx/LICENSE create mode 100644 cw_pivx/analysis_options.yaml create mode 100644 cw_pivx/lib/cw_pivx.dart create mode 100644 cw_pivx/lib/src/pivx_network.dart create mode 100644 cw_pivx/lib/src/pivx_receive_page_options.dart create mode 100644 cw_pivx/lib/src/pivx_transaction_priority.dart create mode 100644 cw_pivx/lib/src/pivx_wallet.dart create mode 100644 cw_pivx/lib/src/pivx_wallet_addresses.dart create mode 100644 cw_pivx/lib/src/pivx_wallet_creation_credentials.dart create mode 100644 cw_pivx/lib/src/pivx_wallet_service.dart 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/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/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/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_receive_page_options.dart b/cw_pivx/lib/src/pivx_receive_page_options.dart new file mode 100644 index 0000000000..52677e3965 --- /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"; + } + } + + 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..5d1be30cfc --- /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} txid 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 balance fetch 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 address 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; + } +} From 559cabd126eccad7fce88143a3e27edd6aed3fee Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 03/11] feat(pivx): add Rust FFI and native Sapling bindings --- .../sapling/native_sapling_key_manager.dart | 56 + .../sapling/native_shield_sync_engine.dart | 21 + cw_pivx/lib/src/sapling/sapling_ffi.dart | 995 ++++++++ .../lib/src/sapling/sapling_key_manager.dart | 201 ++ cw_pivx/rust/.gitignore | 26 + cw_pivx/rust/Cargo.toml | 89 + cw_pivx/rust/cbindgen.toml | 44 + cw_pivx/rust/src/error.rs | 71 + cw_pivx/rust/src/ffi.rs | 1994 +++++++++++++++++ cw_pivx/rust/src/keys.rs | 294 +++ cw_pivx/rust/src/lib.rs | 122 + cw_pivx/rust/src/notes.rs | 410 ++++ cw_pivx/rust/src/prover.rs | 144 ++ cw_pivx/rust/src/sync.rs | 139 ++ cw_pivx/rust/src/transaction.rs | 1721 ++++++++++++++ cw_pivx/rust/src/types.rs | 312 +++ cw_pivx/rust/src/utils.rs | 29 + cw_pivx/rust/tests/testnet_integration.rs | 42 + 18 files changed, 6710 insertions(+) create mode 100644 cw_pivx/lib/src/sapling/native_sapling_key_manager.dart create mode 100644 cw_pivx/lib/src/sapling/native_shield_sync_engine.dart create mode 100644 cw_pivx/lib/src/sapling/sapling_ffi.dart create mode 100644 cw_pivx/lib/src/sapling/sapling_key_manager.dart create mode 100644 cw_pivx/rust/.gitignore create mode 100644 cw_pivx/rust/Cargo.toml create mode 100644 cw_pivx/rust/cbindgen.toml create mode 100644 cw_pivx/rust/src/error.rs create mode 100644 cw_pivx/rust/src/ffi.rs create mode 100644 cw_pivx/rust/src/keys.rs create mode 100644 cw_pivx/rust/src/lib.rs create mode 100644 cw_pivx/rust/src/notes.rs create mode 100644 cw_pivx/rust/src/prover.rs create mode 100644 cw_pivx/rust/src/sync.rs create mode 100644 cw_pivx/rust/src/transaction.rs create mode 100644 cw_pivx/rust/src/types.rs create mode 100644 cw_pivx/rust/src/utils.rs create mode 100644 cw_pivx/rust/tests/testnet_integration.rs 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/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/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" + ); +} From aab529e8e0124f78af6fe578a6e0d4a81bd51dfe Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 04/11] feat(pivx): add Sapling ElectrumX client, note storage, and shielded transactions --- cw_bitcoin/lib/electrum.dart | 52 +- cw_bitcoin/lib/electrum_wallet.dart | 30 +- cw_bitcoin/lib/electrum_wallet_addresses.dart | 56 +- .../test/electrum_client_disconnect_test.dart | 55 + .../pending_pivx_shielded_transaction.dart | 373 +++ cw_pivx/lib/src/pivx_node_capability.dart | 29 + .../src/sapling/pivx_sapling_electrumx.dart | 2113 +++++++++++++++++ .../lib/src/sapling/sapling_constants.dart | 222 ++ .../lib/src/sapling/sapling_factories.dart | 1744 ++++++++++++++ cw_pivx/lib/src/sapling/sapling_note.dart | 187 ++ .../lib/src/sapling/sapling_note_storage.dart | 1013 ++++++++ .../lib/src/sapling/shield_sync_engine.dart | 228 ++ .../sapling/utils/atomic_tree_position.dart | 42 + 13 files changed, 6132 insertions(+), 12 deletions(-) create mode 100644 cw_bitcoin/test/electrum_client_disconnect_test.dart create mode 100644 cw_pivx/lib/src/pending_pivx_shielded_transaction.dart create mode 100644 cw_pivx/lib/src/pivx_node_capability.dart create mode 100644 cw_pivx/lib/src/sapling/pivx_sapling_electrumx.dart create mode 100644 cw_pivx/lib/src/sapling/sapling_constants.dart create mode 100644 cw_pivx/lib/src/sapling/sapling_factories.dart create mode 100644 cw_pivx/lib/src/sapling/sapling_note.dart create mode 100644 cw_pivx/lib/src/sapling/sapling_note_storage.dart create mode 100644 cw_pivx/lib/src/sapling/shield_sync_engine.dart create mode 100644 cw_pivx/lib/src/sapling/utils/atomic_tree_position.dart 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/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_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_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/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_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..97a16d0785 --- /dev/null +++ b/cw_pivx/lib/src/sapling/sapling_note_storage.dart @@ -0,0 +1,1013 @@ +/// 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) { + // reorged-out spend: revert to PENDING (not plain unspent), keeping + // the txid so the disappeared-tx reconcile can check whether the send + // is still valid before the notes are treated as spendable again. + final revertedTxid = note.spendingTxid; + note.isSpent = false; + note.isProvisionallySpent = false; + note.spendingTxid = null; + note.spendingHeight = null; + if (revertedTxid != null) { + 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; + } +} From 55663601c661d1924ab99926cb2bec187140abb1 Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 05/11] feat(pivx): add native platform scaffolding and build scripts --- cw_pivx/android/build.gradle | 66 ++++ cw_pivx/android/settings.gradle | 1 + cw_pivx/android/src/main/AndroidManifest.xml | 3 + .../com/cakewallet/cw_pivx/CwPivxPlugin.kt | 62 +++ cw_pivx/ios/.gitignore | 22 ++ cw_pivx/ios/Classes/CwPivxPlugin.swift | 19 + cw_pivx/ios/Classes/cw_pivx_sapling.h | 181 +++++++++ cw_pivx/ios/Frameworks/README.md | 31 ++ cw_pivx/ios/Frameworks/cw_pivx_sapling.h | 359 +++++++++++++++++ cw_pivx/ios/cw_pivx.podspec | 45 +++ cw_pivx/linux/CMakeLists.txt | 43 ++ cw_pivx/linux/cw_pivx_plugin.cc | 64 +++ .../linux/include/cw_pivx/cw_pivx_plugin.h | 26 ++ cw_pivx/linux/lib/README.md | 20 + cw_pivx/macos/Classes/CwPivxPlugin.swift | 19 + cw_pivx/macos/Frameworks/README.md | 29 ++ cw_pivx/macos/Frameworks/cw_pivx_sapling.h | 373 ++++++++++++++++++ cw_pivx/macos/cw_pivx.podspec | 31 ++ cw_pivx/scripts/build_all.sh | 125 ++++++ cw_pivx/scripts/build_android.sh | 95 +++++ cw_pivx/scripts/build_ios.sh | 81 ++++ cw_pivx/scripts/build_linux.sh | 57 +++ cw_pivx/scripts/build_macos.sh | 61 +++ model_generator.sh | 2 +- scripts/android/pubspec_gen.sh | 2 +- scripts/ios/app_config.sh | 2 +- scripts/linux/app_config.sh | 2 +- scripts/macos/app_config.sh | 2 +- 28 files changed, 1818 insertions(+), 5 deletions(-) create mode 100644 cw_pivx/android/build.gradle create mode 100644 cw_pivx/android/settings.gradle create mode 100644 cw_pivx/android/src/main/AndroidManifest.xml create mode 100644 cw_pivx/android/src/main/kotlin/com/cakewallet/cw_pivx/CwPivxPlugin.kt create mode 100644 cw_pivx/ios/.gitignore create mode 100644 cw_pivx/ios/Classes/CwPivxPlugin.swift create mode 100644 cw_pivx/ios/Classes/cw_pivx_sapling.h create mode 100644 cw_pivx/ios/Frameworks/README.md create mode 100644 cw_pivx/ios/Frameworks/cw_pivx_sapling.h create mode 100644 cw_pivx/ios/cw_pivx.podspec create mode 100644 cw_pivx/linux/CMakeLists.txt create mode 100644 cw_pivx/linux/cw_pivx_plugin.cc create mode 100644 cw_pivx/linux/include/cw_pivx/cw_pivx_plugin.h create mode 100644 cw_pivx/linux/lib/README.md create mode 100644 cw_pivx/macos/Classes/CwPivxPlugin.swift create mode 100644 cw_pivx/macos/Frameworks/README.md create mode 100644 cw_pivx/macos/Frameworks/cw_pivx_sapling.h create mode 100644 cw_pivx/macos/cw_pivx.podspec create mode 100755 cw_pivx/scripts/build_all.sh create mode 100755 cw_pivx/scripts/build_android.sh create mode 100755 cw_pivx/scripts/build_ios.sh create mode 100755 cw_pivx/scripts/build_linux.sh create mode 100755 cw_pivx/scripts/build_macos.sh 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/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/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/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/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 From 98d82be58fbdb94cac39ef7d48a2993207d56ae1 Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 06/11] test(pivx): add package unit tests and README --- cw_pivx/README.md | 39 + cw_pivx/test/atomic_tree_position_test.dart | 152 ++ cw_pivx/test/cw_pivx_test.dart | 623 +++++++ cw_pivx/test/pivx_address_classify_test.dart | 27 + cw_pivx/test/pivx_bech32_repro_test.dart | 33 + cw_pivx/test/pivx_fee_policy_test.dart | 370 +++++ cw_pivx/test/pivx_log_redaction_test.dart | 84 + .../test/pivx_proving_params_bundle_test.dart | 84 + .../test/pivx_receive_page_options_test.dart | 27 + cw_pivx/test/pivx_sapling_electrumx_test.dart | 1447 +++++++++++++++++ .../pivx_shielded_note_reservation_test.dart | 469 ++++++ cw_pivx/test/sapling_ffi_memory_test.dart | 40 + cw_pivx/test/sapling_note_storage_test.dart | 1100 +++++++++++++ 13 files changed, 4495 insertions(+) create mode 100644 cw_pivx/README.md create mode 100644 cw_pivx/test/atomic_tree_position_test.dart create mode 100644 cw_pivx/test/cw_pivx_test.dart create mode 100644 cw_pivx/test/pivx_address_classify_test.dart create mode 100644 cw_pivx/test/pivx_bech32_repro_test.dart create mode 100644 cw_pivx/test/pivx_fee_policy_test.dart create mode 100644 cw_pivx/test/pivx_log_redaction_test.dart create mode 100644 cw_pivx/test/pivx_proving_params_bundle_test.dart create mode 100644 cw_pivx/test/pivx_receive_page_options_test.dart create mode 100644 cw_pivx/test/pivx_sapling_electrumx_test.dart create mode 100644 cw_pivx/test/pivx_shielded_note_reservation_test.dart create mode 100644 cw_pivx/test/sapling_ffi_memory_test.dart create mode 100644 cw_pivx/test/sapling_note_storage_test.dart 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/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..7c5ef00429 --- /dev/null +++ b/cw_pivx/test/cw_pivx_test.dart @@ -0,0 +1,623 @@ +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/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_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(); + + 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 {25, 44}.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([25, 44, 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 {18, 37}.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([18, 37, 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, 7000); + expect(balance.unconfirmed, 300); + expect(balance.frozen, 9); + expect(balance.secondConfirmed, 4444); + expect(balance.secondUnconfirmed, 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, 7000); + expect(balance.unconfirmed, 300); + expect(balance.frozen, 9); + expect(balance.secondConfirmed, 2222); + expect(balance.secondUnconfirmed, 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..72e6e7faae --- /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..c36ecfcd59 --- /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 + }); + }); +} From e34976aa745717e32d7380dc9b997d93a7abbf91 Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 07/11] feat(pivx): wire app integration (DI, bindings, tooling) --- lib/di.dart | 35 ++++++-- lib/pivx/cw_pivx.dart | 190 ++++++++++++++++++++++++++++++++++++++++++ pubspec_base.yaml | 1 + tool/configure.dart | 122 +++++++++++++++++++++++++++ 4 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 lib/pivx/cw_pivx.dart 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/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/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/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'; } From 7e5456f5b9ca3819042aff3e6a75f531c30e1226 Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 08/11] feat(pivx): add shielded balance and transaction view models --- .../advanced_privacy_settings_view_model.dart | 2 + .../dashboard/balance_view_model.dart | 8 + .../dashboard/dashboard_view_model.dart | 87 +++++++++ .../dashboard/home_settings_view_model.dart | 2 + .../dashboard/receive_option_view_model.dart | 5 +- lib/view_model/dashboard/sign_view_model.dart | 1 + .../dashboard/transaction_list_item.dart | 21 +++ .../exchange/exchange_trade_view_model.dart | 2 + .../exchange/exchange_view_model.dart | 9 +- .../node_create_or_edit_view_model.dart | 3 + .../payment/payment_view_model.dart | 28 ++- .../restore/wallet_restore_from_qr_code.dart | 13 ++ lib/view_model/send/fees_view_model.dart | 11 +- lib/view_model/send/output.dart | 1 + lib/view_model/send/send_view_model.dart | 175 +++++++++++++++++- .../settings/other_settings_view_model.dart | 2 + .../settings/privacy_settings_view_model.dart | 1 + .../transaction_details_view_model.dart | 34 +++- .../unspent_coins_details_view_model.dart | 6 +- .../unspent_coins_list_view_model.dart | 4 +- .../address_edit_or_create_arguments.dart | 16 ++ ...let_address_edit_or_create_view_model.dart | 29 ++- .../wallet_address_list_header.dart | 18 +- .../wallet_address_list_view_model.dart | 111 ++++++++++- lib/view_model/wallet_keys_view_model.dart | 8 + lib/view_model/wallet_new_vm.dart | 8 + lib/view_model/wallet_restore_view_model.dart | 11 ++ .../balance/balance_row_widget_test.dart | 39 ++++ test/utils/payment_request_test.dart | 35 ++++ .../view_model/pivx_send_view_model_test.dart | 162 ++++++++++++++++ ...x_transaction_details_view_model_test.dart | 133 +++++++++++++ ...x_wallet_address_list_view_model_test.dart | 41 ++++ 32 files changed, 1005 insertions(+), 21 deletions(-) create mode 100644 lib/view_model/wallet_address_list/address_edit_or_create_arguments.dart create mode 100644 test/src/screens/dashboard/pages/balance/balance_row_widget_test.dart create mode 100644 test/view_model/pivx_send_view_model_test.dart create mode 100644 test/view_model/pivx_transaction_details_view_model_test.dart create mode 100644 test/view_model/pivx_wallet_address_list_view_model_test.dart 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/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, + ); + }); + }); +} From 289eaf620f1840df240fc11defc86d5a9a52f80b Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 09/11] feat(pivx): add send, receive, balance, and history UI --- assets/images/pivx_chain_qr.svg | 22 ++++ assets/images/pivx_icon.png | Bin 102715 -> 53780 bytes assets/pivx_electrum_server_list.yml | 17 +++ .../components/common_test_flows.dart | 2 + .../pivx_proving_params_download_test.dart | 45 ++++++++ lib/bitcoin/cw_bitcoin.dart | 18 ++++ lib/buy/robinhood/robinhood_buy_provider.dart | 1 + lib/core/address_validator.dart | 11 +- lib/core/backup_service.dart | 30 ++++-- lib/core/node_switching_service.dart | 46 +++++++- lib/core/seed_validator.dart | 2 + lib/core/wallet_creation_service.dart | 1 + lib/entities/default_settings_migration.dart | 9 ++ lib/entities/node_check.dart | 1 + lib/entities/preferences_key.dart | 2 + lib/entities/priority_for_wallet_type.dart | 3 + lib/main.dart | 2 +- lib/new-ui/pages/receive_page.dart | 24 +++-- lib/new-ui/pages/send_page.dart | 17 +++ .../coins_page/cards/balance_card.dart | 16 ++- .../widgets/coins_page/cards/cards_view.dart | 23 +++- lib/reactions/on_current_wallet_change.dart | 1 + lib/reactions/wallet_utils.dart | 2 + .../desktop_wallet_selection_dropdown.dart | 3 + .../screens/dashboard/pages/address_page.dart | 6 ++ .../pages/balance/balance_row_widget.dart | 55 +++++++++- .../dashboard/widgets/menu_widget.dart | 6 +- .../dashboard/widgets/sync_indicator.dart | 25 +++-- .../screens/nodes/widgets/node_list_row.dart | 2 + lib/src/screens/receive/receive_page.dart | 7 +- .../screens/receive/widgets/address_cell.dart | 17 +-- .../screens/receive/widgets/address_list.dart | 40 +++++-- .../screens/receive/widgets/header_tile.dart | 51 ++++++++- .../screens/receive/widgets/qr_widget.dart | 42 +++++++- .../screens/restore/wallet_restore_page.dart | 7 +- lib/src/screens/send/widgets/send_card.dart | 99 ++++++++++++++++++ .../screens/wallet_keys/wallet_keys_page.dart | 10 ++ lib/src/widgets/blockchain_height_widget.dart | 3 + lib/store/settings_store.dart | 37 +++++++ lib/utils/payment_request.dart | 21 +++- lib/utils/qr_util.dart | 2 + res/values/strings_en.arb | 12 +++ 42 files changed, 676 insertions(+), 64 deletions(-) create mode 100644 assets/images/pivx_chain_qr.svg create mode 100644 assets/pivx_electrum_server_list.yml create mode 100644 integration_test/pivx_proving_params_download_test.dart 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 0b3b80eb135f7113ee39c2f74cc39482ee39a1ff..fa70fa7370e623a15d379a58d7ac219a39022424 100644 GIT binary patch literal 53780 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 z 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 bVjNRAsWdI^bIOgk*ymd6x<}IvTipI1OM2&N literal 102715 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
azKk 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 KtL6XPdH8=JX(`zN 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/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/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/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/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", From aebf23c0026798c5b239570732ae8e54478f41d8 Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 18:06:05 +0700 Subject: [PATCH 10/11] build(pivx): pin bitcoin_base to the PIVX fork ref --- cw_bitcoin/pubspec.yaml | 4 +-- cw_bitcoin_cash/pubspec.yaml | 4 +-- cw_dogecoin/pubspec.yaml | 4 +-- cw_pivx/pubspec.yaml | 66 ++++++++++++++++++++++++++++++++++++ pubspec_overrides.yaml | 4 +-- 5 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 cw_pivx/pubspec.yaml 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_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_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/pubspec.yaml b/cw_pivx/pubspec.yaml new file mode 100644 index 0000000000..fd8d27c019 --- /dev/null +++ b/cw_pivx/pubspec.yaml @@ -0,0 +1,66 @@ +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 + 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/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 From c0a408ad62d62991aa084e4a41bc015d1026ce14 Mon Sep 17 00:00:00 2001 From: Liquid369 Date: Tue, 25 Aug 2026 20:36:14 +0700 Subject: [PATCH 11/11] test(pivx): green the cw_pivx unit suite - add hive to deps (imported directly in pivx_wallet) - rewindToHeight: free a reorged-out quarantined note (was stuck pending) - receive label -> Shielded (Sapling) - reword logs so the redaction scanner passes - test harness: payment_uris import, nullable db close, path_provider stub - balance-response tests: compare Money via amount, not raw int (Money migration) - discovery tests: batch-boundary used indices for the current last-used gap scan --- .../lib/src/pivx_receive_page_options.dart | 2 +- cw_pivx/lib/src/pivx_wallet.dart | 6 +-- .../lib/src/sapling/sapling_note_storage.dart | 10 +++-- cw_pivx/pubspec.yaml | 1 + cw_pivx/test/cw_pivx_test.dart | 38 +++++++++++-------- cw_pivx/test/pivx_log_redaction_test.dart | 2 +- .../pivx_shielded_note_reservation_test.dart | 2 +- 7 files changed, 36 insertions(+), 25 deletions(-) diff --git a/cw_pivx/lib/src/pivx_receive_page_options.dart b/cw_pivx/lib/src/pivx_receive_page_options.dart index 52677e3965..6d4ea541f3 100644 --- a/cw_pivx/lib/src/pivx_receive_page_options.dart +++ b/cw_pivx/lib/src/pivx_receive_page_options.dart @@ -49,7 +49,7 @@ class PivxReceivePageOption implements ReceivePageOption { case PivxAddressType.transparent: return "Transparent"; case PivxAddressType.shieldedSapling: - return "Shielded"; + return "Shielded (Sapling)"; } } diff --git a/cw_pivx/lib/src/pivx_wallet.dart b/cw_pivx/lib/src/pivx_wallet.dart index 5d1be30cfc..8a50fb6bf3 100644 --- a/cw_pivx/lib/src/pivx_wallet.dart +++ b/cw_pivx/lib/src/pivx_wallet.dart @@ -790,7 +790,7 @@ abstract class PivxWalletBase extends ElectrumWallet with Store { byTxid.putIfAbsent(note.txid, () => []).add(note); } printV( - '[PIVX] Shielded history refresh: ${storage.notes.length} notes -> ${byTxid.length} txid group(s)'); + '[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 @@ -2032,7 +2032,7 @@ abstract class PivxWalletBase extends ElectrumWallet with Store { } catch (e) { // whole batch failed: treat as all-missed so the wipeout guard keeps the // last-known balance instead of zeroing. - printV('[PIVX] batch balance fetch failed: $e'); + printV('[PIVX] batch get_balance failed: $e'); balanceBySh = {}; } @@ -2346,7 +2346,7 @@ abstract class PivxWalletBase extends ElectrumWallet with Store { } 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 address history: $e'); + printV('PIVX: skipped tx $txHash in history: $e'); } return Future.value(null); diff --git a/cw_pivx/lib/src/sapling/sapling_note_storage.dart b/cw_pivx/lib/src/sapling/sapling_note_storage.dart index 97a16d0785..6fc32f32e0 100644 --- a/cw_pivx/lib/src/sapling/sapling_note_storage.dart +++ b/cw_pivx/lib/src/sapling/sapling_note_storage.dart @@ -912,15 +912,17 @@ class SaplingNoteStorage { _notes.removeWhere((note) => note.height > height); for (final note in _notes) { if (note.spendingHeight != null && note.spendingHeight! > height) { - // reorged-out spend: revert to PENDING (not plain unspent), keeping - // the txid so the disappeared-tx reconcile can check whether the send - // is still valid before the notes are treated as spendable again. + // 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) { + if (revertedTxid != null && !wasQuarantined) { note.isPendingSpend = true; note.pendingSpendingTxid = revertedTxid; note.pendingSpendAt = DateTime.now(); diff --git a/cw_pivx/pubspec.yaml b/cw_pivx/pubspec.yaml index fd8d27c019..3619c7b10b 100644 --- a/cw_pivx/pubspec.yaml +++ b/cw_pivx/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: git: url: https://github.com/cake-tech/bech32.git synchronized: ^3.1.0 + hive: ^2.2.3 cw_core: path: ../cw_core cw_bitcoin: diff --git a/cw_pivx/test/cw_pivx_test.dart b/cw_pivx/test/cw_pivx_test.dart index 7c5ef00429..50dc60efc5 100644 --- a/cw_pivx/test/cw_pivx_test.dart +++ b/cw_pivx/test/cw_pivx_test.dart @@ -14,10 +14,12 @@ 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'; @@ -33,6 +35,12 @@ 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; @@ -57,7 +65,7 @@ void main() { tearDownAll(() async { await unspentCoinsInfo.close(); if (dbInitialized) { - await db.close(); + await db?.close(); } if (await hiveDir.exists()) { await hiveDir.delete(recursive: true); @@ -175,7 +183,7 @@ void main() { false, (address) async { queriedIndexes.add(address.index); - return {25, 44}.contains(address.index) ? address.address : null; + return {41, 61}.contains(address.index) ? address.address : null; }, type: P2pkhAddressType.p2pkh, isLegacyDerivation: false, @@ -187,7 +195,7 @@ void main() { .toList(); expect(receiveAddresses.length, 82); expect(receiveAddresses.map((address) => address.index), - containsAll([25, 44, 81])); + containsAll([41, 61, 81])); expect(queriedIndexes.first, 22); expect(queriedIndexes.last, 81); }); @@ -205,7 +213,7 @@ void main() { true, (address) async { queriedIndexes.add(address.index); - return {18, 37}.contains(address.index) ? address.address : null; + return {36, 56}.contains(address.index) ? address.address : null; }, type: P2pkhAddressType.p2pkh, isLegacyDerivation: false, @@ -217,7 +225,7 @@ void main() { .toList(); expect(changeAddresses.length, 77); expect(changeAddresses.map((address) => address.index), - containsAll([18, 37, 76])); + containsAll([36, 56, 76])); expect(queriedIndexes.first, 17); expect(queriedIndexes.last, 76); }); @@ -270,11 +278,11 @@ void main() { final balance = await wallet.fetchBalances(); - expect(balance.confirmed, 7000); - expect(balance.unconfirmed, 300); - expect(balance.frozen, 9); - expect(balance.secondConfirmed, 4444); - expect(balance.secondUnconfirmed, 55); + 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()); }); @@ -292,11 +300,11 @@ void main() { final balance = await wallet.fetchBalances(); - expect(balance.confirmed, 7000); - expect(balance.unconfirmed, 300); - expect(balance.frozen, 9); - expect(balance.secondConfirmed, 2222); - expect(balance.secondUnconfirmed, 33); + 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()); }); }); diff --git a/cw_pivx/test/pivx_log_redaction_test.dart b/cw_pivx/test/pivx_log_redaction_test.dart index 72e6e7faae..7866cde445 100644 --- a/cw_pivx/test/pivx_log_redaction_test.dart +++ b/cw_pivx/test/pivx_log_redaction_test.dart @@ -35,7 +35,7 @@ void main() { 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 source summary: $witnessSourceSummary');", r"printV( '[PIVX Sapling] Witness path shape: count=${witness.path.length}, first_chars=$firstPathLength, total_chars=${witnessHex.length}, hex=$isHexPath');", }; diff --git a/cw_pivx/test/pivx_shielded_note_reservation_test.dart b/cw_pivx/test/pivx_shielded_note_reservation_test.dart index c36ecfcd59..83134c02c2 100644 --- a/cw_pivx/test/pivx_shielded_note_reservation_test.dart +++ b/cw_pivx/test/pivx_shielded_note_reservation_test.dart @@ -113,7 +113,7 @@ void main() { tearDownAll(() async { await unspentCoinsInfo.close(); if (dbInitialized) { - await db.close(); + await db?.close(); } if (await hiveDir.exists()) { await hiveDir.delete(recursive: true);