Found while wiring hardware-wallet tag backup on Android (synonymdev/bitkit-android#1163, follow-up to #113). Verified against v0.5.5; the read-side half reproduced on regtest with the bitkit-docker Trezor emulator.
Problem
For received activities, address is treated as the identity of a pre-activity metadata record on both the write and the read side. Because a receive address can legitimately serve many transactions, at most one received record per (wallet_id, address) can survive, and the one that is read back is arbitrary.
The primary key does not say this. It is (wallet_id, payment_id), so callers reasonably expect one record per transaction.
Write side: storing a record deletes its siblings
upsert_pre_activity_metadata deletes by address for every record it writes:
DELETE FROM pre_activity_metadata
WHERE wallet_id = ?1 AND address = ?2 AND is_receive = 1
So writing N received records that share one address leaves only the last one. Every earlier record is silently discarded even though each has a distinct payment_id. add_pre_activity_metadata does the same thing for a single record.
For Android this means a user who tagged several received transactions on the same address gets one tag set back after a restore, and loses the rest, with nothing reported.
Read side: re-attachment picks an arbitrary sibling
apply_pre_activity_metadata_for_onchain picks the lookup key from the payment direction:
PaymentType::Received => transfer(..., &activity.address, activity_id, true) // WHERE address = ? AND is_receive = 1
PaymentType::Sent => transfer(..., &activity.tx_id, activity_id, false) // WHERE payment_id = ?
Received activities match on address alone, so the metadata attaches to whichever transaction is found first.
Reproduced on regtest: a hardware wallet address reused across ten received transactions. Metadata stored against transaction 5e23be45… (5009 sats) re-attached to sibling transaction 9af07885… (5000 sats) on the same address. Right wallet, right address, wrong transaction.
Why address-keying is load-bearing today
Consumers cannot work around either half. apply_pre_activity_metadata_for_onchain selects the branch from the activity's tx_type, so a received activity is only ever looked up by address. A consumer that stores a received record without an address to avoid the delete would simply never have it re-attached.
The result is that a received transaction's metadata cannot be addressed individually, in either direction.
Requested change
Use tx_id as the identity when it is present, on both sides:
- Write: only replace by address when the incoming record has no
tx_id. When it does, let (wallet_id, payment_id) be the key it already claims to be, so sibling transactions on the same address coexist.
- Read: prefer an exact
tx_id match, falling back to the address match when the record has no tx_id.
Genuine pre-activity records — staged before a transaction exists — have no tx_id yet, so their current replace-by-address and match-by-address behaviour is preserved. The change only affects records that already name a transaction.
Acceptance criteria
- Writing several received records that share an address but have distinct
tx_ids keeps all of them.
- Writing a received record with no
tx_id still replaces the existing address-keyed record, as today.
- Metadata carrying a
tx_id re-attaches to that exact transaction.
- Metadata without a
tx_id keeps the current address behaviour.
- A reused receive address with several received transactions attaches each record to its own transaction.
- Tests cover both keys on both sides, including several tagged receives to one address surviving a write-then-reattach round trip.
Found while wiring hardware-wallet tag backup on Android (synonymdev/bitkit-android#1163, follow-up to #113). Verified against
v0.5.5; the read-side half reproduced on regtest with thebitkit-dockerTrezor emulator.Problem
For received activities,
addressis treated as the identity of a pre-activity metadata record on both the write and the read side. Because a receive address can legitimately serve many transactions, at most one received record per(wallet_id, address)can survive, and the one that is read back is arbitrary.The primary key does not say this. It is
(wallet_id, payment_id), so callers reasonably expect one record per transaction.Write side: storing a record deletes its siblings
upsert_pre_activity_metadatadeletes by address for every record it writes:So writing N received records that share one address leaves only the last one. Every earlier record is silently discarded even though each has a distinct
payment_id.add_pre_activity_metadatadoes the same thing for a single record.For Android this means a user who tagged several received transactions on the same address gets one tag set back after a restore, and loses the rest, with nothing reported.
Read side: re-attachment picks an arbitrary sibling
apply_pre_activity_metadata_for_onchainpicks the lookup key from the payment direction:Received activities match on address alone, so the metadata attaches to whichever transaction is found first.
Reproduced on regtest: a hardware wallet address reused across ten received transactions. Metadata stored against transaction
5e23be45…(5009 sats) re-attached to sibling transaction9af07885…(5000 sats) on the same address. Right wallet, right address, wrong transaction.Why address-keying is load-bearing today
Consumers cannot work around either half.
apply_pre_activity_metadata_for_onchainselects the branch from the activity'stx_type, so a received activity is only ever looked up by address. A consumer that stores a received record without an address to avoid the delete would simply never have it re-attached.The result is that a received transaction's metadata cannot be addressed individually, in either direction.
Requested change
Use
tx_idas the identity when it is present, on both sides:tx_id. When it does, let(wallet_id, payment_id)be the key it already claims to be, so sibling transactions on the same address coexist.tx_idmatch, falling back to the address match when the record has notx_id.Genuine pre-activity records — staged before a transaction exists — have no
tx_idyet, so their current replace-by-address and match-by-address behaviour is preserved. The change only affects records that already name a transaction.Acceptance criteria
tx_ids keeps all of them.tx_idstill replaces the existing address-keyed record, as today.tx_idre-attaches to that exact transaction.tx_idkeeps the current address behaviour.