Impact
A DashPay contact chain watches exactly 20 addresses, indices 0–19, and never grows. Once a contact has sent 20 payments, every later payment lands on an address the receiving wallet never derived, so it is never matched by the BIP158 scan and never enters the balance. A rescan cannot recover it, because the address does not exist client-side. Coins are on-chain and spendable by the seed but invisible to the wallet.
Also affects DashpayExternalAccount, so the wallet stops tracking its own outgoing contact payments past index 19.
Reproduction (testnet, confirmed)
Twenty sequential contact payments filled indices 0–19 and were all received. The 21st was not:
txid b837bcb2f4f50c29502cd32549b3c369f28157b4ead872b7f41351cebb121ab0
value 0.00029000 DASH -> yR3kpYnbcPipq4982WcEK2mCBrQXh7P39K
block 1555216
Receiver synced through block 1555217, service running: zero transactions rows, zero txos rows, and zero core_addresses rows for that destination. The address was never derived. Highest known index 19, all 20 used, unspent total unchanged.
The window never moved across all 20 uses, on all 14 of that wallet's contact accounts: at highest_used 19 it was still 0–19, where highest_used + gap_limit would be 0–39.
The sender's log shows broadcastTransaction … complete and the explorer confirms the transaction, so it was sent. The receiver had just caught 19 consecutive payments on the same chain, so it was not stalled.
Root cause
Verified on master e5423d2a, and on af88edf where it was reproduced.
key-wallet/src/transaction_checking/wallet_checker.rs:262-291 marks addresses used unconditionally, then maintains the gap limit only if an xpub resolves:
for address_info in account_match.account_type_match.all_involved_addresses() {
account.mark_address_used(&address_info.address);
}
let account_type_to_check = account_match.account_type_match.to_account_type_to_check();
let xpub_opt = wallet.extended_public_key_for_account_type(
&account_type_to_check,
account_match.account_type_match.account_index(),
);
if let Some(xpub) = xpub_opt {
...
let _ = pool.maintain_gap_limit(&key_source);
}
For DashPay accounts xpub_opt is always None, so maintain_gap_limit never runs and the pool keeps its construction size. AddressPool does not retain a key source, so it cannot extend itself.
The None comes from key-wallet/src/wallet/helper.rs:770:
AccountTypeToCheck::DashpayReceivingFunds |
AccountTypeToCheck::DashpayExternalAccount => {
// Currently not retrieved via this helper
None
}
This is correct as written. AccountTypeToCheck is a fieldless enum, so its DashPay variants carry no index, user_identity_id or friend_identity_id, and contact accounts are keyed by all three. The helper cannot identify which contact account is meant. The bug is that the caller relies on a lookup that structurally cannot answer for these types.
Because mark_address_used runs above the check, used flags advance perfectly while the window stays frozen. The account looks healthy and fully consumed in the data, which is why this is easy to miss.
Both pools are also constructed with a bare literal 20 rather than a named constant, at managed_account_type.rs:645 and :667.
Suggested fix
- In
wallet_checker.rs, resolve the key source from the account's fully-keyed AccountType rather than the lossy AccountTypeToCheck. The call site already has it via to_account_type(), account_of_type() in account_collection.rs already handles both DashPay variants, and Account.account_xpub is public. DIP-15 receiving addresses derive from that xpub alone, so no seed or private key is needed.
- Replace the hardcoded
20 with a named constant. Note DEFAULT_CONTACT_GAP_LIMIT = 10 exists in dashpay/platform at rs-platform-wallet/src/wallet/identity/crypto/dip14.rs:254, quoting DIP-15, but has no consumer anywhere. Decide which value is intended.
- Consider whether
mark_address_used should run when the pool cannot be maintained, since that mismatch is what hides the failure.
Related
dashpay/platform PR #4740 is adjacent but does not fix this. It registers contact accounts with the scanner and rewinds to the request height; it touches no gap-limit or address-pool code. A wallet with it merged still loses payment 21.
Environment
dashpay/rust-dashcore master e5423d2a (verified) and af88edf (reproduced), via dashpay/platform rs-platform-wallet. Dash Platform SDK 4.2.0-dev.8, Android wallet 12.0.0, Dash testnet.
Impact
A DashPay contact chain watches exactly 20 addresses, indices 0–19, and never grows. Once a contact has sent 20 payments, every later payment lands on an address the receiving wallet never derived, so it is never matched by the BIP158 scan and never enters the balance. A rescan cannot recover it, because the address does not exist client-side. Coins are on-chain and spendable by the seed but invisible to the wallet.
Also affects
DashpayExternalAccount, so the wallet stops tracking its own outgoing contact payments past index 19.Reproduction (testnet, confirmed)
Twenty sequential contact payments filled indices 0–19 and were all received. The 21st was not:
Receiver synced through block 1555217, service running: zero
transactionsrows, zerotxosrows, and zerocore_addressesrows for that destination. The address was never derived. Highest known index 19, all 20 used, unspent total unchanged.The window never moved across all 20 uses, on all 14 of that wallet's contact accounts: at
highest_used19 it was still 0–19, wherehighest_used + gap_limitwould be 0–39.The sender's log shows
broadcastTransaction … completeand the explorer confirms the transaction, so it was sent. The receiver had just caught 19 consecutive payments on the same chain, so it was not stalled.Root cause
Verified on master
e5423d2a, and onaf88edfwhere it was reproduced.key-wallet/src/transaction_checking/wallet_checker.rs:262-291marks addresses used unconditionally, then maintains the gap limit only if an xpub resolves:For DashPay accounts
xpub_optis alwaysNone, somaintain_gap_limitnever runs and the pool keeps its construction size.AddressPooldoes not retain a key source, so it cannot extend itself.The
Nonecomes fromkey-wallet/src/wallet/helper.rs:770:This is correct as written.
AccountTypeToCheckis a fieldless enum, so its DashPay variants carry noindex,user_identity_idorfriend_identity_id, and contact accounts are keyed by all three. The helper cannot identify which contact account is meant. The bug is that the caller relies on a lookup that structurally cannot answer for these types.Because
mark_address_usedruns above the check, used flags advance perfectly while the window stays frozen. The account looks healthy and fully consumed in the data, which is why this is easy to miss.Both pools are also constructed with a bare literal
20rather than a named constant, atmanaged_account_type.rs:645and:667.Suggested fix
wallet_checker.rs, resolve the key source from the account's fully-keyedAccountTyperather than the lossyAccountTypeToCheck. The call site already has it viato_account_type(),account_of_type()inaccount_collection.rsalready handles both DashPay variants, andAccount.account_xpubis public. DIP-15 receiving addresses derive from that xpub alone, so no seed or private key is needed.20with a named constant. NoteDEFAULT_CONTACT_GAP_LIMIT = 10exists indashpay/platformatrs-platform-wallet/src/wallet/identity/crypto/dip14.rs:254, quoting DIP-15, but has no consumer anywhere. Decide which value is intended.mark_address_usedshould run when the pool cannot be maintained, since that mismatch is what hides the failure.Related
dashpay/platformPR #4740 is adjacent but does not fix this. It registers contact accounts with the scanner and rewinds to the request height; it touches no gap-limit or address-pool code. A wallet with it merged still loses payment 21.Environment
dashpay/rust-dashcoremastere5423d2a(verified) andaf88edf(reproduced), viadashpay/platformrs-platform-wallet. Dash Platform SDK 4.2.0-dev.8, Android wallet 12.0.0, Dash testnet.