feat(paynym): add P2TR (taproot) payment address support
What changed, and why it matters
This commit adds support for Bitcoin Taproot (P2TR) addresses to the Paynym feature in Stack Wallet. Paynyms are reusable payment codes that let users receive payments without sharing a new address each time. The change extends the existing support for legacy and SegWit addresses to also handle Taproot addresses, and refactors the code to choose address types more cleanly. There is no indication in the commit that this fixes a security vulnerability.
No security action required based on this commit alone. Reviewers may want to verify that the new `_pubKeyToP2TRAddress` implementation correctly uses coinlib's Taproot API and that `isTaprootEnabled()` checks are consistent with the wallet's supported coins, but these are correctness/functional considerations rather than identified vulnerabilities.
Security signals we found
No security-relevant keywords in commit title or message
No CVE, advisory, or vendor security notice referenced
Feature addition: P2TR/Taproot address support for Paynym
Refactoring of address-type selection from boolean to enum
No changes to authentication, authorization, input validation, or cryptography beyond address derivation
Evidence from the diff
The patch introduces a taproot boolean field to PaynymAccountLite and replaces the previous isSegwit/generateSegwitAddress boolean flags with a DerivePathType enum (bip44, bip84, bip86). It adds _pubKeyToP2TRAddress to derive P2TR addresses via coinlib’s Taproot API, and _paynymAddressAndType to select the correct address string and AddressType based on derivation path. Existing callers are updated to pass the appropriate DerivePathType, and history restoration now iterates over a list of derivation types rather than duplicating loops for segwit/non-segwit. The diff is a feature addition with no obvious cryptographic or logic flaws visible in the changed code alone.
Changed components
lib/models/paynym/paynym_account_lite.dartlib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dartInspect captured patch +142 / −142
diff --git a/lib/models/paynym/paynym_account_lite.dart b/lib/models/paynym/paynym_account_lite.dart
index 694efb7..33c6eba 100644
--- a/lib/models/paynym/paynym_account_lite.dart
+++ b/lib/models/paynym/paynym_account_lite.dart
@@ -13,25 +13,29 @@ class PaynymAccountLite {
final String nymName;
final String code;
final bool segwit;
+ final bool taproot;
PaynymAccountLite(
this.nymId,
this.nymName,
this.code,
- this.segwit,
- );
+ this.segwit, {
+ this.taproot = false,
+ });
PaynymAccountLite.fromMap(Map<String, dynamic> map)
: nymId = map["nymId"] as String,
nymName = map["nymName"] as String,
code = map["code"] as String,
- segwit = map["segwit"] as bool;
+ segwit = map["segwit"] as bool,
+ taproot = map["taproot"] as bool? ?? false;
Map<String, dynamic> toMap() => {
"nymId": nymId,
"nymName": nymName,
"code": code,
"segwit": segwit,
+ "taproot": taproot,
};
@override
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
index c184033..5319edf 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
@@ -91,26 +91,31 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
Future<Address> currentReceivingPaynymAddress({
required PaymentCode sender,
- required bool isSegwit,
+ required DerivePathType derivePathType,
}) async {
final keys = await lookupKey(sender.toString());
+ final AddressType filterType;
+ switch (derivePathType) {
+ case DerivePathType.bip86:
+ filterType = AddressType.p2tr;
+ break;
+ case DerivePathType.bip84:
+ filterType = AddressType.p2wpkh;
+ break;
+ case DerivePathType.bip44:
+ default:
+ filterType = AddressType.p2pkh;
+ break;
+ }
+
final address =
await mainDB
.getAddresses(walletId)
.filter()
.subTypeEqualTo(AddressSubType.paynymReceive)
.and()
- .group((q) {
- if (isSegwit) {
- return q
- .typeEqualTo(AddressType.p2sh)
- .or()
- .typeEqualTo(AddressType.p2wpkh);
- } else {
- return q.typeEqualTo(AddressType.p2pkh);
- }
- })
+ .typeEqualTo(filterType)
.and()
.anyOf<String, Address>(
keys,
@@ -123,7 +128,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
final generatedAddress = await _generatePaynymReceivingAddress(
sender: sender,
index: 0,
- generateSegwitAddress: isSegwit,
+ derivePathType: derivePathType,
);
final existing =
@@ -134,23 +139,67 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
.findFirst();
if (existing == null) {
- // Add that new address
await mainDB.putAddress(generatedAddress);
} else {
- // we need to update the address
await mainDB.updateAddress(existing, generatedAddress);
}
- return currentReceivingPaynymAddress(isSegwit: isSegwit, sender: sender);
+ return currentReceivingPaynymAddress(
+ derivePathType: derivePathType,
+ sender: sender,
+ );
} else {
return address;
}
}
+ /// Convert a compressed public key to a P2TR (taproot) address string.
+ String _pubKeyToP2TRAddress(Uint8List compressedPubKey) {
+ final ecPubKey = coinlib.ECPublicKey(compressedPubKey);
+ final taproot = coinlib.Taproot(internalKey: ecPubKey);
+ final addr = coinlib.P2TRAddress.fromTaproot(
+ taproot,
+ hrp: cryptoCurrency.networkParams.bech32Hrp,
+ );
+ return addr.toString();
+ }
+
+ ({String address, AddressType type}) _paynymAddressAndType({
+ required PaymentAddress paymentAddress,
+ required DerivePathType derivePathType,
+ required bool isSend,
+ }) {
+ switch (derivePathType) {
+ case DerivePathType.bip86:
+ final pubKey = isSend
+ ? paymentAddress.getDerivedSendPublicKey()
+ : paymentAddress.getDerivedReceivePublicKey();
+ return (
+ address: _pubKeyToP2TRAddress(pubKey),
+ type: isSend ? AddressType.nonWallet : AddressType.p2tr,
+ );
+ case DerivePathType.bip84:
+ return (
+ address: isSend
+ ? paymentAddress.getSendAddressP2WPKH()
+ : paymentAddress.getReceiveAddressP2WPKH(),
+ type: isSend ? AddressType.nonWallet : AddressType.p2wpkh,
+ );
+ case DerivePathType.bip44:
+ default:
+ return (
+ address: isSend
+ ? paymentAddress.getSendAddressP2PKH()
+ : paymentAddress.getReceiveAddressP2PKH(),
+ type: isSend ? AddressType.nonWallet : AddressType.p2pkh,
+ );
+ }
+ }
+
Future<Address> _generatePaynymReceivingAddress({
required PaymentCode sender,
required int index,
- required bool generateSegwitAddress,
+ required DerivePathType derivePathType,
}) async {
final root = await _getRootNode();
final node = root.derivePath(
@@ -164,14 +213,15 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
index: 0,
);
- final addressString =
- generateSegwitAddress
- ? paymentAddress.getReceiveAddressP2WPKH()
- : paymentAddress.getReceiveAddressP2PKH();
+ final result = _paynymAddressAndType(
+ paymentAddress: paymentAddress,
+ derivePathType: derivePathType,
+ isSend: false,
+ );
final address = Address(
walletId: walletId,
- value: addressString,
+ value: result.address,
publicKey: [],
derivationIndex: index,
derivationPath:
@@ -180,7 +230,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
index,
testnet: info.coin.network.isTestNet,
),
- type: generateSegwitAddress ? AddressType.p2wpkh : AddressType.p2pkh,
+ type: result.type,
subType: AddressSubType.paynymReceive,
otherData: await storeCode(sender.toString()),
);
@@ -191,7 +241,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
Future<Address> _generatePaynymSendAddress({
required PaymentCode other,
required int index,
- required bool generateSegwitAddress,
+ required DerivePathType derivePathType,
bip32.BIP32? mySendBip32Node,
}) async {
final node = mySendBip32Node ?? await deriveNotificationBip32Node();
@@ -203,14 +253,15 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
index: index,
);
- final addressString =
- generateSegwitAddress
- ? paymentAddress.getSendAddressP2WPKH()
- : paymentAddress.getSendAddressP2PKH();
+ final result = _paynymAddressAndType(
+ paymentAddress: paymentAddress,
+ derivePathType: derivePathType,
+ isSend: true,
+ );
final address = Address(
walletId: walletId,
- value: addressString,
+ value: result.address,
publicKey: [],
derivationIndex: index,
derivationPath:
@@ -219,7 +270,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
index,
testnet: info.coin.network.isTestNet,
),
- type: AddressType.nonWallet,
+ type: result.type,
subType: AddressSubType.paynymSend,
otherData: await storeCode(other.toString()),
);
@@ -229,11 +280,11 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
Future<void> checkCurrentPaynymReceivingAddressForTransactions({
required PaymentCode sender,
- required bool isSegwit,
+ required DerivePathType derivePathType,
}) async {
final address = await currentReceivingPaynymAddress(
sender: sender,
- isSegwit: isSegwit,
+ derivePathType: derivePathType,
);
final txCount = await fetchTxCount(
@@ -246,7 +297,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
final nextAddress = await _generatePaynymReceivingAddress(
sender: sender,
index: address.derivationIndex + 1,
- generateSegwitAddress: isSegwit,
+ derivePathType: derivePathType,
);
final existing =
@@ -257,16 +308,14 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
.findFirst();
if (existing == null) {
- // Add that new address
await mainDB.putAddress(nextAddress);
} else {
- // we need to update the address
await mainDB.updateAddress(existing, nextAddress);
}
// keep checking until address with no tx history is set as current
await checkCurrentPaynymReceivingAddressForTransactions(
sender: sender,
- isSegwit: isSegwit,
+ derivePathType: derivePathType,
);
}
}
@@ -278,15 +327,23 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
futures.add(
checkCurrentPaynymReceivingAddressForTransactions(
sender: code,
- isSegwit: true,
+ derivePathType: DerivePathType.bip84,
),
);
futures.add(
checkCurrentPaynymReceivingAddressForTransactions(
sender: code,
- isSegwit: false,
+ derivePathType: DerivePathType.bip44,
),
);
+ if (code.isTaprootEnabled()) {
+ futures.add(
+ checkCurrentPaynymReceivingAddressForTransactions(
+ sender: code,
+ derivePathType: DerivePathType.bip86,
+ ),
+ );
+ }
}
await Future.wait(futures);
}
@@ -386,10 +443,19 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
);
} else {
final myPrivateKeyNode = await deriveNotificationBip32Node();
+ final DerivePathType sendDeriveType;
+ if (txData.paynymAccountLite!.taproot) {
+ sendDeriveType = DerivePathType.bip86;
+ } else if (txData.paynymAccountLite!.segwit) {
+ sendDeriveType = DerivePathType.bip84;
+ } else {
+ sendDeriveType = DerivePathType.bip44;
+ }
+
final sendToAddress = await nextUnusedSendAddressFrom(
pCode: paymentCode,
privateKeyNode: myPrivateKeyNode,
- isSegwit: txData.paynymAccountLite!.segwit,
+ derivePathType: sendDeriveType,
);
return prepareSend(
@@ -411,7 +477,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
/// and your own private key
Future<Address> nextUnusedSendAddressFrom({
required PaymentCode pCode,
- required bool isSegwit,
+ required DerivePathType derivePathType,
required bip32.BIP32 privateKeyNode,
int startIndex = 0,
}) async {
@@ -448,7 +514,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
final address = await _generatePaynymSendAddress(
other: pCode,
index: i,
- generateSegwitAddress: isSegwit,
+ derivePathType: derivePathType,
mySendBip32Node: privateKeyNode,
);
@@ -1390,12 +1456,19 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
final List<Future<void>> futures = [];
for (final code in codes) {
+ final types = <DerivePathType>[DerivePathType.bip44];
+ if (code.isSegWitEnabled()) {
+ types.add(DerivePathType.bip84);
+ }
+ if (code.isTaprootEnabled()) {
+ types.add(DerivePathType.bip86);
+ }
futures.add(
_restoreHistoryWith(
other: code,
maxUnusedAddressGap: maxUnusedAddressGap,
maxNumberOfIndexesToCheck: maxNumberOfIndexesToCheck,
- checkSegwitAsWell: code.isSegWitEnabled(),
+ derivePathTypes: types,
),
);
}
@@ -1405,144 +1478,67 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
Future<void> _restoreHistoryWith({
required PaymentCode other,
- required bool checkSegwitAsWell,
+ required List<DerivePathType> derivePathTypes,
required int maxUnusedAddressGap,
required int maxNumberOfIndexesToCheck,
}) async {
- // https://en.bitcoin.it/wiki/BIP_0047#Path_levels
const maxCount = 2147483647;
assert(maxNumberOfIndexesToCheck < maxCount);
final mySendBip32Node = await deriveNotificationBip32Node();
-
final List<Address> addresses = [];
- int receivingGapCounter = 0;
- int outgoingGapCounter = 0;
-
- // non segwit receiving
- for (
- int i = 0;
- i < maxNumberOfIndexesToCheck &&
- receivingGapCounter < maxUnusedAddressGap;
- i++
- ) {
- if (receivingGapCounter < maxUnusedAddressGap) {
+
+ for (final derivePathType in derivePathTypes) {
+ int receivingGap = 0;
+ for (
+ int i = 0;
+ i < maxNumberOfIndexesToCheck && receivingGap < maxUnusedAddressGap;
+ i++
+ ) {
final address = await _generatePaynymReceivingAddress(
sender: other,
index: i,
- generateSegwitAddress: false,
+ derivePathType: derivePathType,
);
-
addresses.add(address);
-
final count = await fetchTxCount(
addressScriptHash: cryptoCurrency.addressToScriptHash(
address: address.value,
),
);
-
if (count > 0) {
- receivingGapCounter = 0;
+ receivingGap = 0;
} else {
- receivingGapCounter++;
+ receivingGap++;
}
}
- }
- // non segwit sends
- for (
- int i = 0;
- i < maxNumberOfIndexesToCheck && outgoingGapCounter < maxUnusedAddressGap;
- i++
- ) {
- if (outgoingGapCounter < maxUnusedAddressGap) {
+ int outgoingGap = 0;
+ for (
+ int i = 0;
+ i < maxNumberOfIndexesToCheck && outgoingGap < maxUnusedAddressGap;
+ i++
+ ) {
final address = await _generatePaynymSendAddress(
other: other,
index: i,
- generateSegwitAddress: false,
+ derivePathType: derivePathType,
mySendBip32Node: mySendBip32Node,
);
-
addresses.add(address);
-
final count = await fetchTxCount(
addressScriptHash: cryptoCurrency.addressToScriptHash(
address: address.value,
),
);
-
if (count > 0) {
- outgoingGapCounter = 0;
+ outgoingGap = 0;
} else {
- outgoingGapCounter++;
+ outgoingGap++;
}
}
}
- if (checkSegwitAsWell) {
- int receivingGapCounterSegwit = 0;
- int outgoingGapCounterSegwit = 0;
- // segwit receiving
- for (
- int i = 0;
- i < maxNumberOfIndexesToCheck &&
- receivingGapCounterSegwit < maxUnusedAddressGap;
- i++
- ) {
- if (receivingGapCounterSegwit < maxUnusedAddressGap) {
- final address = await _generatePaynymReceivingAddress(
- sender: other,
- index: i,
- generateSegwitAddress: true,
- );
-
- addresses.add(address);
-
- final count = await fetchTxCount(
- addressScriptHash: cryptoCurrency.addressToScriptHash(
- address: address.value,
- ),
- );
-
- if (count > 0) {
- receivingGapCounterSegwit = 0;
- } else {
- receivingGapCounterSegwit++;
- }
- }
- }
-
- // segwit sends
- for (
- int i = 0;
- i < maxNumberOfIndexesToCheck &&
- outgoingGapCounterSegwit < maxUnusedAddressGap;
- i++
- ) {
- if (outgoingGapCounterSegwit < maxUnusedAddressGap) {
- final address = await _generatePaynymSendAddress(
- other: other,
- index: i,
- generateSegwitAddress: true,
- mySendBip32Node: mySendBip32Node,
- );
-
- addresses.add(address);
-
- final count = await fetchTxCount(
- addressScriptHash: cryptoCurrency.addressToScriptHash(
- address: address.value,
- ),
- );
-
- if (count > 0) {
- outgoingGapCounterSegwit = 0;
- } else {
- outgoingGapCounterSegwit++;
- }
- }
- }
- }
await mainDB.updateOrPutAddresses(addresses);
}
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.