Summary
After removing and re-pairing a hardware wallet, a past transfer-to-spending shows up as an ordinary send instead of a transfer.
Found and fixed on iOS (synonymdev/bitkit-ios#648, reproduced and verified there). This report is derived from reading the Android code, not reproduced on a device — the same two mechanisms are present, so the same journey should reproduce it. Worth confirming with a Bridge emulator before acting on it.
Steps to reproduce
- Pair a hardware wallet.
- Transfer to spending from it, and wait for the channel to become usable (this is what makes the transfer settle — see below).
- Settings → remove the paired device.
- Re-pair the same device.
- Open the activity list: the funding transaction is back, but rendered as a plain send. Expected: a transfer row.
Root cause
Five steps, each a deliberate behaviour on its own; together they lose the flag.
TransferRepo.syncTransferStates re-marks the funding tx via persistResolvedChannel → markActivityAsTransfer — TransferRepo.kt:263.
- That path only ever sees unsettled transfers:
syncTransferStates reads transferDao.getActiveTransfers() (TransferRepo.kt:144), whose query is WHERE isSettled = 0 (TransferDao.kt:33).
- Once the channel is ready the transfer is settled (
markSettled, TransferRepo.kt:159), so it permanently leaves that set and step 1 never runs for it again.
- Removing the wallet deletes its activities:
HwWalletRepo.removeDevice → activityRepo.deleteForWallet(it) (HwWalletRepo.kt:326).
- On re-pair the watcher rediscovers the tx from Electrum, and
mergeHwSnapshot carries isTransfer forward only from a stored row — isTransfer = onchain.v1.isTransfer || stored.isTransfer (CoreService.kt:261). Step 4 deleted that row, so there is nothing to carry, and it lands as a plain send.
This predicts the bug is specific to settled transfers. A transfer whose channel had not yet become usable is still in the active set, so step 1 would re-mark it on re-pair.
Fixable with no new retention
The obvious fix — keep the hardware wallet's activity rows after removal — would be the wrong trade: removal should mean removal.
It is not needed. The transfer fact is already retained on the Bitkit side, legitimately: the Lightning channel is still open and the LSP order still exists, neither of which the user asked to delete. TransferEntity holds fundingTxId and channelId, and removeDevice never touches TransferRepo/transferDao — only activityRepo, trackedWalletIds and lastPersistedHwSnapshots.
So the match runs in the safe direction — rediscovered tx id → existing local transfer record, never remembered-hardware-data → display:
- Nothing hardware-specific is retained past removal; remove-and-never-re-pair leaves no trace.
- The tx id is not remembered by the app — the device's own watcher re-supplies it on re-pair.
- These records would be retained identically for a user who never owned a hardware wallet.
Suggested fix (mirrors the iOS one)
Recover the flag inside mergeHwSnapshot rather than as a follow-up re-marking pass, so it lands in the same transaction as the snapshot write and does not run a lookup on every watcher poll.
-
Give mergeHwSnapshot a transferChannelIdsByFundingTxId: Map<String, String> parameter, defaulted to empty.
-
Apply it after the existing stored-row merge and guarded on !isTransfer, so stored metadata always wins and recovery only fills gaps:
if (!merged.isTransfer) {
transferChannelIdsByFundingTxId[merged.txId]?.let { channelId ->
merged = merged.copy(isTransfer = true, channelId = merged.channelId ?: channelId)
}
}
-
Build the map where replaceHwSnapshot is called, from transfers that have both fundingTxId and channelId. Unsettled ones can be skipped — syncTransferStates still handles those, and a settled transfer always has a channel id since settling requires a ready channel.
Two Android-specific notes:
- The current structure early-returns when no stored row matches, which is exactly the re-pair case. On iOS that branch had to be restructured so recovery sits outside it; check whether
mergeHwSnapshot needs the same.
transferDao.getByFundingTxId already exists (TransferDao.kt:39), so a per-tx query is an option instead of building a map — probably preferable given the snapshot is usually small.
Match on transaction id, not activity id: a re-paired wallet may rebuild its rows under different activity ids.
iOS reference
synonymdev/bitkit-ios#648 — HwSnapshotMerge.plan, ActivityService.replaceHwSnapshot, and HwWalletManager.transferChannelIdsByFundingTxId(), with unit tests in BitkitTests/HwSnapshotMergeTests.swift covering recovery with no stored row, stored metadata winning, tx-id matching, and the empty-map no-op.
One coverage gap worth repeating here: the merge rule is unit tested, but the wiring from transfer storage into the snapshot call is not, because the hardware wallet manager tests inject their own persistence seam and bypass it. The end-to-end journey needs the manual test in the Steps above.
Summary
After removing and re-pairing a hardware wallet, a past transfer-to-spending shows up as an ordinary send instead of a transfer.
Found and fixed on iOS (synonymdev/bitkit-ios#648, reproduced and verified there). This report is derived from reading the Android code, not reproduced on a device — the same two mechanisms are present, so the same journey should reproduce it. Worth confirming with a Bridge emulator before acting on it.
Steps to reproduce
Root cause
Five steps, each a deliberate behaviour on its own; together they lose the flag.
TransferRepo.syncTransferStatesre-marks the funding tx viapersistResolvedChannel→markActivityAsTransfer—TransferRepo.kt:263.syncTransferStatesreadstransferDao.getActiveTransfers()(TransferRepo.kt:144), whose query isWHERE isSettled = 0(TransferDao.kt:33).markSettled,TransferRepo.kt:159), so it permanently leaves that set and step 1 never runs for it again.HwWalletRepo.removeDevice→activityRepo.deleteForWallet(it)(HwWalletRepo.kt:326).mergeHwSnapshotcarriesisTransferforward only from a stored row —isTransfer = onchain.v1.isTransfer || stored.isTransfer(CoreService.kt:261). Step 4 deleted that row, so there is nothing to carry, and it lands as a plain send.This predicts the bug is specific to settled transfers. A transfer whose channel had not yet become usable is still in the active set, so step 1 would re-mark it on re-pair.
Fixable with no new retention
The obvious fix — keep the hardware wallet's activity rows after removal — would be the wrong trade: removal should mean removal.
It is not needed. The transfer fact is already retained on the Bitkit side, legitimately: the Lightning channel is still open and the LSP order still exists, neither of which the user asked to delete.
TransferEntityholdsfundingTxIdandchannelId, andremoveDevicenever touchesTransferRepo/transferDao— onlyactivityRepo,trackedWalletIdsandlastPersistedHwSnapshots.So the match runs in the safe direction — rediscovered tx id → existing local transfer record, never remembered-hardware-data → display:
Suggested fix (mirrors the iOS one)
Recover the flag inside
mergeHwSnapshotrather than as a follow-up re-marking pass, so it lands in the same transaction as the snapshot write and does not run a lookup on every watcher poll.Give
mergeHwSnapshotatransferChannelIdsByFundingTxId: Map<String, String>parameter, defaulted to empty.Apply it after the existing stored-row merge and guarded on
!isTransfer, so stored metadata always wins and recovery only fills gaps:Build the map where
replaceHwSnapshotis called, from transfers that have bothfundingTxIdandchannelId. Unsettled ones can be skipped —syncTransferStatesstill handles those, and a settled transfer always has a channel id since settling requires a ready channel.Two Android-specific notes:
mergeHwSnapshotneeds the same.transferDao.getByFundingTxIdalready exists (TransferDao.kt:39), so a per-tx query is an option instead of building a map — probably preferable given the snapshot is usually small.Match on transaction id, not activity id: a re-paired wallet may rebuild its rows under different activity ids.
iOS reference
synonymdev/bitkit-ios#648 —
HwSnapshotMerge.plan,ActivityService.replaceHwSnapshot, andHwWalletManager.transferChannelIdsByFundingTxId(), with unit tests inBitkitTests/HwSnapshotMergeTests.swiftcovering recovery with no stored row, stored metadata winning, tx-id matching, and the empty-map no-op.One coverage gap worth repeating here: the merge rule is unit tested, but the wiring from transfer storage into the snapshot call is not, because the hardware wallet manager tests inject their own persistence seam and bypass it. The end-to-end journey needs the manual test in the Steps above.