fix: display proper address in zcash transactions (#3457)

cyan committed Aug 7, 2026 at 02:28 UTC c93843f3347a0d8f816000eb84190f4fa494f363
2 files changed +98 -107
cw_zcash/lib/src/zcash_wallet.dart
+97 -106
@@ -521,10 +521,10 @@ abstract class ZcashWalletBase
521
522 static const _dispPhrase = "Received to disposable address";
523
524 - bool _hasExternalOutputs(final ZkoolTx tx) =>
525 - tx.outputsWithAddress.any((final o) => !_addressBelongsToWallet(o.address));
524 + bool _hasExternalOutputs(final ZkoolTx tx, final Set<String> ownedAddresses) =>
525 + tx.outputsWithAddress.any((final o) => !_isOwnedAddress(o.address, ownedAddresses));
526
527 - bool _isIronwoodMigrationTx(final ZkoolTx tx) {
527 + bool _isIronwoodMigrationTx(final ZkoolTx tx, final Set<String> ownedAddresses) {
528 // zkool classifies migration as selfTransfer with fee-sized net value.
529 if (tx.type != TxType.selfTransfer) {
530 return false;
@@ -540,7 +540,7 @@ abstract class ZcashWalletBase
540 return true;
541 }
542 // Orchard → Orchard split step (SD notes created, all outputs are ours).
543 - if (tx.orchardReceived > BigInt.zero && !_hasExternalOutputs(tx)) {
543 + if (tx.orchardReceived > BigInt.zero && !_hasExternalOutputs(tx, ownedAddresses)) {
544 return true;
545 }
546 // Orchard → Ironwood before the IW note is attached to tx details.
@@ -577,10 +577,7 @@ abstract class ZcashWalletBase
577 .whereType<String>()
578 .where((final m) => m.isNotEmpty)
579 .firstOrNull;
580 - final recipientAddresses = _recipientAddresses(
581 - accountNotes.map((final n) => n.address).whereType<String>(),
582 - direction,
583 - );
580 + final recipientAddresses = const <String>[];
581
582 final info = ZcashTransactionInfo(
583 id: txHash,
@@ -608,17 +605,20 @@ abstract class ZcashWalletBase
605 final bool isShieldAction = false,
606 final TransactionDirection? directionOverride,
607 final BigInt? amountOverride,
608 + required final Set<String> ownedAddresses,
609 }) {
610 final confirmations = tx.height > 0 && currentHeight >= tx.height
611 ? currentHeight - tx.height + 1
612 : 0;
613 final memo = extraMemo != null ? "${tx.memo ?? ''}\n$extraMemo".trim() : tx.memo;
616 - final isMigration = directionOverride == null && _isIronwoodMigrationTx(tx);
614 + final isMigration = directionOverride == null && _isIronwoodMigrationTx(tx, ownedAddresses);
615 final direction = directionOverride ??
616 (isMigration ? TransactionDirection.outgoing : tx.direction);
619 - final recipientAddresses = _recipientAddresses(_paymentOutputAddresses(tx), direction);
617 final amount = amountOverride ??
618 (isMigration ? _migrationDisplayAmount(tx) : tx.value);
619 + final recipientAddresses = direction == TransactionDirection.outgoing
620 + ? _outgoingRecipientAddresses(tx, ownedAddresses: ownedAddresses)
621 + : const <String>[];
622 final info = ZcashTransactionInfo(
623 id: tx.txHash,
624 amount: Money(amount, currency),
@@ -641,14 +641,18 @@ abstract class ZcashWalletBase
641 return info;
642 }
643
644 - bool _isShieldActionTx(final ZkoolTx tx, {required final Set<String> rotationSweepHashes}) {
644 + bool _isShieldActionTx(
645 + final ZkoolTx tx, {
646 + required final Set<String> rotationSweepHashes,
647 + required final Set<String> ownedAddresses,
648 + }) {
649 if (ZcashWalletService.isAutoshieldTx(tx.txHash)) {
650 return true;
651 }
652 if (rotationSweepHashes.contains(tx.txHash)) {
653 return true;
654 }
651 - if (_isPayToSelfAutoshield(tx)) {
655 + if (_isPayToSelfAutoshield(tx, ownedAddresses)) {
656 return true;
657 }
658 if (tx.direction == TransactionDirection.outgoing &&
@@ -658,7 +662,7 @@ abstract class ZcashWalletBase
662 return false;
663 }
664
661 - bool _isPayToSelfAutoshield(final ZkoolTx tx) {
665 + bool _isPayToSelfAutoshield(final ZkoolTx tx, final Set<String> ownedAddresses) {
666 if (tx.type != TxType.shield && tx.type != TxType.transparentSelfTransfer) {
667 return false;
668 }
@@ -669,18 +673,23 @@ abstract class ZcashWalletBase
673 return false;
674 }
675 for (final dest in tx.outputAddresses) {
672 - if (_addressBelongsToWallet(dest)) {
676 + if (_isOwnedAddress(dest, ownedAddresses)) {
677 return true;
678 }
679 }
680 return tx.orchardReceived > BigInt.zero;
681 }
682
679 - bool _shouldSplitAutoshieldTx(final ZkoolTx tx, {required final bool isShield}) {
683 + bool _shouldSplitAutoshieldTx(
684 + final ZkoolTx tx, {
685 + required final bool isShield,
686 + required final Set<String> ownedAddresses,
687 + }) {
688 if (!isShield) {
689 return false;
690 }
683 - if (ZcashWalletService.isAutoshieldTx(tx.txHash) || _isPayToSelfAutoshield(tx)) {
691 + if (ZcashWalletService.isAutoshieldTx(tx.txHash) ||
692 + _isPayToSelfAutoshield(tx, ownedAddresses)) {
693 return tx.transparentOrSaplingSpent > BigInt.zero && tx.orchardReceived > BigInt.zero;
694 }
695 return false;
@@ -724,104 +733,57 @@ abstract class ZcashWalletBase
733 }
734 }
735
727 - bool _addressBelongsToWallet(final String addr) {
728 - final addrs = walletAddresses;
729 - if (addrs.containsAddress(addr) ||
730 - addrs.hiddenAddresses.contains(addr) ||
731 - addrs.usedAddresses.contains(addr)) {
732 - return true;
736 + Future<Set<String>> _ownedAddressSet(final zkool_coin.Coin coin) async {
737 + final owned = <String>{};
738 + for (final address in await zkool_account.listOwnedAddresses(c: coin)) {
739 + if (address.isEmpty) {
740 + continue;
741 + }
742 + owned.add(address);
743 + if (address.startsWith('u')) {
744 + owned.addAll(_uaReceivers(address));
745 + }
746 }
747 + final addrs = walletAddresses;
748 for (final infos in addrs.addressInfos.values) {
749 for (final info in infos) {
736 - if (info.address == addr) {
737 - return true;
738 - }
750 + owned.add(info.address);
751 }
752 }
741 - for (final own in [
742 - addrs.orchardAddress,
743 - addrs.unifiedAddress,
744 - addrs.saplingAddress,
745 - addrs.transparentAddress,
746 - addrs.address,
747 - ]) {
748 - if (own == null || own.isEmpty || own.startsWith('unknown ')) {
749 - continue;
750 - }
751 - if (addr == own || addr.startsWith(own) || own.startsWith(addr)) {
752 - return true;
753 - }
754 - }
755 - return false;
753 + owned.addAll(addrs.hiddenAddresses);
754 + owned.addAll(addrs.usedAddresses);
755 + return owned;
756 }
757
758 - List<String> _paymentOutputAddresses(final ZkoolTx tx) {
759 - final all = tx.outputsWithAddress.toList();
760 - final external = all.where((final o) => !_addressBelongsToWallet(o.address)).toList();
761 - final outputs = external.isNotEmpty ? external : all;
762 - final transparent = outputs.where((final o) => o.pool == NotePool.transparent.index).toList();
758 + bool _isOwnedAddress(final String addr, final Set<String> ownedAddresses) =>
759 + ownedAddresses.contains(addr);
760 +
761 + List<String> _outgoingRecipientAddresses(
762 + final ZkoolTx tx, {
763 + required final Set<String> ownedAddresses,
764 + }) {
765 + var outputs = tx.outputsWithAddress
766 + .where((final o) => !_isOwnedAddress(o.address, ownedAddresses))
767 + .toList();
768 + if (outputs.isEmpty) {
769 + return [];
770 + }
771 +
772 + final transparent =
773 + outputs.where((final o) => o.pool == NotePool.transparent.index).toList();
774 if (transparent.length >= 2) {
764 - return transparent.map((final o) => o.address).toList();
775 + outputs = transparent;
776 }
766 - return outputs.map((final o) => o.address).toList();
777 +
778 + return _dedupeAddresses(outputs.map((final o) => o.address));
779 }
780
769 - List<String> _recipientAddresses(
770 - final Iterable<String> raw,
771 - final TransactionDirection direction,
772 - ) {
781 + List<String> _dedupeAddresses(final Iterable<String> raw) {
782 final seen = <String>{};
774 - final addresses = [
783 + return [
784 for (final address in raw)
785 if (address.trim().isNotEmpty && seen.add(address.trim())) address.trim(),
786 ];
778 - if (addresses.isEmpty) {
779 - return [];
780 - }
781 -
782 - final external = direction == TransactionDirection.incoming
783 - ? addresses
784 - : [
785 - for (final address in addresses)
786 - if (!_addressBelongsToWallet(address)) address,
787 - ];
788 - final list = external.isNotEmpty ? external : addresses;
789 - if (list.isEmpty) {
790 - return [];
791 - }
792 -
793 - final embedded = {
794 - for (final address in list)
795 - if (address.startsWith('u')) ..._uaReceivers(address),
796 - };
797 - var result = embedded.isEmpty
798 - ? list
799 - : [
800 - for (final address in list)
801 - if (address.startsWith('u') || !embedded.contains(address)) address,
802 - ];
803 - if (result.isEmpty) {
804 - result = list;
805 - }
806 -
807 - final standalone = [
808 - for (final address in result)
809 - if (!address.startsWith('u') && !embedded.contains(address)) address,
810 - ];
811 - if (standalone.length >= 2) {
812 - result = standalone;
813 - }
814 -
815 - if (direction == TransactionDirection.incoming) {
816 - return [
817 - result.firstWhere(
818 - (final a) => a.startsWith('u'),
819 - orElse: () =>
820 - result.firstWhere((final a) => a.startsWith('z'), orElse: () => result.first),
821 - ),
822 - ];
823 - }
824 - return result;
787 }
788
789 Set<String> _uaReceivers(final String ua) {
@@ -839,9 +801,10 @@ abstract class ZcashWalletBase
801 @override
802 Future<Map<String, ZcashTransactionInfo>> fetchTransactions() async {
803 await ZcashWalletService.loadShieldTxs();
842 - final (txs, currentHeight) = await runWithCoin(
804 + final (txs, currentHeight, ownedAddresses) = await runWithCoin(
805 accountId: accountId,
806 func: (coin) async {
807 + final owned = await _ownedAddressSet(coin);
808 final txsI = await zkool_account.listTxHistory(c: coin);
809 final txsA = await Future.wait(
810 txsI.map((final tx) => zkool_account.getTxDetails(idTx: tx.id, c: coin)),
@@ -857,7 +820,7 @@ abstract class ZcashWalletBase
820 } catch (e) {
821 printV("failed to get height: $e");
822 }
860 - return (txs, currentHeight);
823 + return (txs, currentHeight, owned);
824 },
825 );
826 final Map<String, ZcashTransactionInfo> byHash = {};
@@ -871,11 +834,25 @@ abstract class ZcashWalletBase
834 if (tx.direction == TransactionDirection.incoming) {
835 _offerTx(
836 byHash,
874 - _zcashInfoFromZkoolTx(tx, currentHeight, extraMemo: _dispPhrase, isRotationReceive: true),
837 + _zcashInfoFromZkoolTx(
838 + tx,
839 + currentHeight,
840 + extraMemo: _dispPhrase,
841 + isRotationReceive: true,
842 + ownedAddresses: ownedAddresses,
843 + ),
844 );
845 continue;
846 }
878 - _offerTx(byHash, _zcashInfoFromZkoolTx(tx, currentHeight, isShieldAction: true));
847 + _offerTx(
848 + byHash,
849 + _zcashInfoFromZkoolTx(
850 + tx,
851 + currentHeight,
852 + isShieldAction: true,
853 + ownedAddresses: ownedAddresses,
854 + ),
855 + );
856 }
857
858 final Map<String, ZcashTransactionInfo> splitEntries = {};
@@ -884,8 +861,12 @@ abstract class ZcashWalletBase
861 if (tx.height > 0) {
862 ZcashMempoolService.instance.removeTx(tx.txHash);
863 }
887 - final isShield = _isShieldActionTx(tx, rotationSweepHashes: rotationSweepHashes);
888 - if (_shouldSplitAutoshieldTx(tx, isShield: isShield)) {
864 + final isShield = _isShieldActionTx(
865 + tx,
866 + rotationSweepHashes: rotationSweepHashes,
867 + ownedAddresses: ownedAddresses,
868 + );
869 + if (_shouldSplitAutoshieldTx(tx, isShield: isShield, ownedAddresses: ownedAddresses)) {
870 byHash.remove(tx.txHash);
871 splitEntries[_txResultKey(tx.txHash, suffix: '_shield')] = _zcashInfoFromZkoolTx(
872 tx,
@@ -893,16 +874,26 @@ abstract class ZcashWalletBase
874 isShieldAction: true,
875 directionOverride: TransactionDirection.outgoing,
876 amountOverride: tx.transparentOrSaplingSpent,
877 + ownedAddresses: ownedAddresses,
878 );
879 splitEntries[_txResultKey(tx.txHash, suffix: '_recv')] = _zcashInfoFromZkoolTx(
880 tx,
881 currentHeight,
882 directionOverride: TransactionDirection.incoming,
883 amountOverride: tx.orchardReceived,
884 + ownedAddresses: ownedAddresses,
885 );
886 continue;
887 }
905 - _offerTx(byHash, _zcashInfoFromZkoolTx(tx, currentHeight, isShieldAction: isShield));
888 + _offerTx(
889 + byHash,
890 + _zcashInfoFromZkoolTx(
891 + tx,
892 + currentHeight,
893 + isShieldAction: isShield,
894 + ownedAddresses: ownedAddresses,
895 + ),
896 + );
897 }
898
899 final knownHashes = {
cw_zcash/pubspec.yaml
+1 -1
@@ -21,7 +21,7 @@ dependencies:
21 # path: ../../zkool2
22 git:
23 url: https://github.com/cake-tech/zkool2.git
24 - ref: 6329d4aacb4568ee80ff84baba5ea93b07ae1013
24 + ref: 16fd3d11575f850939f498c3ea75d391915456d1
25
26 path_provider: any
27 sqflite_common_ffi: any