Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,19 @@ interface TransactionMetadataDao {

@MapInfo(keyColumn = "txId")
@Query(
"""SELECT txId, memo, service, customIconId FROM transaction_metadata
"""SELECT txId, memo, service, customIconId FROM transaction_metadata
WHERE memo != '' OR service IS NOT NULL OR customIconId IS NOT NULL"""
)
fun observePresentableMetadata(): Flow<Map<TxId, PresentableTxMetadata>>

/** One-shot [observePresentableMetadata] restricted to [txIds] (callers chunk below SQLite's 999-variable cap). */
@MapInfo(keyColumn = "txId")
@Query(
"""SELECT txId, memo, service, customIconId FROM transaction_metadata
WHERE txId IN (:txIds) AND (memo != '' OR service IS NOT NULL OR customIconId IS NOT NULL)"""
)
suspend fun loadPresentableMetadata(txIds: List<TxId>): Map<TxId, PresentableTxMetadata>

@Query("SELECT * FROM transaction_metadata WHERE timestamp <= :end and timestamp >= :start")
fun observeByTimestampRange(start: Long, end: Long): Flow<List<TransactionMetadata>>

Expand Down
72 changes: 72 additions & 0 deletions wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,33 @@ class TxDisplayCacheService @Inject constructor(
}
txDisplayCacheDao.insertAll(entries)
}

// Changed txids with NO resolvable wrapper: on a restored post-cutover
// device the affected rows are SDK-planned "fallback" metadata rows the
// held dashj wallet cannot render, so the rebuild above never reaches
// them — decorate the display-cache row directly by txid instead
// (memo/service/custom icon only; see [planMetadataRowDecorations]).
val wrappedTxIds = affectedWrappers.flatMapTo(mutableSetOf()) { it.transactions.keys }
val wrapperlessIds = changedIds.filterNot { it in wrappedTxIds }
if (wrapperlessIds.isNotEmpty()) {
val existingRows = HashMap<String, TxDisplayCacheEntry>(wrapperlessIds.size)
for (chunk in wrapperlessIds.chunked(500)) {
txDisplayCacheDao.getEntriesByIds(chunk).forEach { existingRows[it.rowId] = it }
}
val decorated = planMetadataRowDecorations(
wrapperlessIds.associateWith { id -> newMetadata[TxId.wrap(id)] },
existingRows
)
if (decorated.isNotEmpty()) {
txDisplayCacheDao.insertAll(decorated)
_currentPagingSource.value?.invalidate()
log.info(
"metadata re-decorated {} wrapperless row(s): {}",
decorated.size,
decorated.take(8).joinToString { "${it.rowId.take(8)} memo=${it.comment.length} chars" }
)
}
}
}
.catch { e -> log.error("metadata flow error", e) }
.launchIn(serviceScope)
Expand Down Expand Up @@ -1440,6 +1467,51 @@ internal fun planSwapRowDecorations(
decorated.takeIf { it != existing }
}

/**
* PURE planner for the METADATA DECORATION of already-cached display rows whose
* transaction the held dashj wallet cannot render — the host-testable core of
* [TxDisplayCacheService]'s wrapperless metadata re-decoration.
*
* The metadata-change observer's rebuild path needs a dashj [TransactionWrapper], but on
* a restored post-cutover device the display rows are SDK-planned and their metadata
* arrives as "SDK fallback rows" ([WalletTransactionMetadataProvider]) for txs the
* wallet does not hold — so no wrapper ever resolves and the row could never learn its
* memo (verified in the field: "platform metadata merged" with zero "row … bound with
* metadata" renders across whole sessions). Like [planSwapRowDecorations], this planner
* works off the display-cache row keyed by txid instead.
*
* Decoration ONLY, mirroring what the wrapper path's SDK-stamp preserve-guards let
* metadata drive on such a row: [TxDisplayCacheEntry.comment] (the memo — metadata is
* its source of truth, so a removed metadata row clears it), [TxDisplayCacheEntry.service]
* (never un-classified) and [TxDisplayCacheEntry.customIconId] (the merchant/service
* icon, rendered from live metadata at display time). Value, direction, icon, title,
* status, time, rate, contact identity and the filter bucket are never touched.
* Idempotent: a row that already matches is not returned.
*
* Stamping `service` here does NOT opt the row out of the SDK planner's later status
* transitions: [de.schildbach.wallet.service.platform.sdk.planL1DisplaySync] and
* [de.schildbach.wallet.service.platform.sdk.planL1InstantLockRowUpdate] treat a
* service classification as decoration, not as the never-touch rich-row signal —
* a row decorated while "Sending"/"Processing" still settles on its lock.
*
* @param metadataByTxId the CHANGED txids mapped to their new presentable metadata
* (null = the metadata row was removed).
*/
internal fun planMetadataRowDecorations(
metadataByTxId: Map<String, PresentableTxMetadata?>,
existingByRowId: Map<String, TxDisplayCacheEntry>
): List<TxDisplayCacheEntry> = metadataByTxId.mapNotNull { (txId, meta) ->
// Only rows the cache already displays are decorated; a row planned LATER is
// born decorated by the SDK planner's own build-time join.
val existing = existingByRowId[txId] ?: return@mapNotNull null
val decorated = existing.copy(
comment = meta?.memo ?: "",
service = meta?.service ?: existing.service,
customIconId = meta?.customIconId?.toString() ?: existing.customIconId
)
decorated.takeIf { it != existing }
}

/**
* PURE merge of a dashj-rebuilt display [entry] over the [existing] cached row —
* the host-testable core of [TxDisplayCacheService.mergePreservingSdkStamped]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.bitcoinj.core.Coin
import org.dash.wallet.common.Configuration
import org.dash.wallet.common.data.PresentableTxMetadata
import org.dash.wallet.common.data.TxId
import org.dash.wallet.common.data.WalletUIConfig
import org.dash.wallet.common.services.NotificationService
import org.slf4j.LoggerFactory
Expand Down Expand Up @@ -449,13 +451,16 @@ internal const val L1_NOTIFY_RECENCY_WINDOW_MS = 24L * 60 * 60 * 1000
* record; incoming inserts within the recency window also notify.
* - Existing rows are updated ONLY to reflect lock knowledge dashj is
* blind to post-cutover, and only when the row is a plain send/receive
* (no service, no gift card, no error, not CoinJoin):
* (no gift card, no swap, no error, not CoinJoin):
* - title "Sending" → "Sent" once the SDK saw any lock/confirmation;
* - secondary "Processing" cleared once the tx is locked OR in a block
* (dashj never shows "Processing" for a BUILDING tx — see
* [de.schildbach.wallet.ui.transactions.TxResourceMapper]);
* - secondary "Confirming" cleared only once INSTANT_LOCKED or
* CHAINLOCKED (dashj keeps it while building unlocked <6 confs).
* A row whose only extra semantics is a metadata-supplied `service`
* classification still takes these status edges (its title/status ARE
* the plain pending texts) but none of the value/rate/shape re-stamps.
* Everything else is left byte-identical.
*/
internal fun planL1DisplaySync(
Expand Down Expand Up @@ -486,6 +491,14 @@ internal fun planL1DisplaySync(
// direction/value (and, once a row is cached correctly, is never regressed —
// the re-plan only fires when an authoritative net is present).
signedNetByTxid: Map<String, Long> = emptyMap(),
// Presentable tx metadata per txid (memo / service / custom icon), read app-side
// before this pure planner runs — the SAME join the dashj-era builder applies
// ([de.schildbach.wallet.service.TxDisplayCacheService]'s renderEntry), so a row
// whose metadata already exists at build time (platform metadata synced before
// the L1 scan reached the tx) is born decorated instead of waiting for a
// metadata-change emission. Decoration only — never direction/value. Empty = no
// metadata known (rows insert bare, as before).
metadataByTxid: Map<String, PresentableTxMetadata> = emptyMap(),
// Whether [records] came from the SDK's persisted `transactions` table (the Room
// SNAPSHOT feed) — one wallet-wide row per txid, and therefore DEFINITIVE for a
// plain row's direction/amount. False for the engine's instant tx feed, whose
Expand Down Expand Up @@ -526,18 +539,22 @@ internal fun planL1DisplaySync(
val existing = existingByRowId[record.txidHex]

if (existing == null) {
// Build-time metadata join (renderEntry parity): memo, service and the
// custom (merchant/service) icon. The direction shape above stays the
// record's — metadata never drives value or direction.
val meta = metadataByTxid[record.txidHex]
inserts += TxDisplayCacheEntry(
rowId = plan.rowId,
title = resolve(plan.titleRes),
valueSatoshis = plan.valueDuffs,
iconType = plan.iconType,
iconBgType = plan.iconBgType,
statusText = if (plan.statusRes != -1) resolve(plan.statusRes) else "",
comment = "",
comment = meta?.memo ?: "",
transactionAmount = 1,
time = if (plan.timestampMs > 0) plan.timestampMs else nowMs,
hasErrors = false,
service = null,
service = meta?.service,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Stamp the current fiat rate on every fresh SDK-discovered row,
// mirroring BlockchainServiceImpl.onCoinsReceived — the held dashj
// wallet never sees these SDK-only txs, so nothing else records
Expand All @@ -556,7 +573,8 @@ internal fun planL1DisplaySync(
contactDisplayName = contact?.displayName,
contactAvatarUrl = contact?.avatarUrl,
contactUserId = contact?.userId,
filterFlags = plan.filterFlags
filterFlags = plan.filterFlags,
customIconId = meta?.customIconId?.toString()
)
sdkAuthoritative += plan.rowId
if (plan.isIncoming && record.netAmountDuffs > 0 &&
Expand All @@ -572,21 +590,34 @@ internal fun planL1DisplaySync(
//
// A DEX swap row is one of those: its title/icon come from the `swap_orders`
// record ([de.schildbach.wallet.service.planSwapRowDecorations]) and the SDK
// record cannot reproduce them. `swapStatus` is checked as well as `service`
// record cannot reproduce them. `swapStatus` is checked rather than `service`
// because the service column alone is not proof: a rebuild that raced an
// unpopulated metadata map leaves a swap row plainly rendered with service
// null, and the definitive re-stamp below would then re-title it "Sending" —
// permanently for a Maya drain, whose SDK `context` never leaves the mempool
// (verified on-device, 2026-08-07 Maya field test). The swap reconciler
// restores swapStatus on the next display-cache write signal, so this guard
// then holds the row stable instead of flip-flopping once per sync pass.
if (existing.hasErrors || existing.service != null || existing.swapStatus != null ||
if (existing.hasErrors || existing.swapStatus != null ||
(existing.filterFlags and TxDisplayCacheEntry.FLAG_GIFT_CARD) != 0 ||
(existing.filterFlags and TxDisplayCacheEntry.FLAG_COINJOIN) != 0
) {
continue
}
sdkAuthoritative += record.txidHex
// A service-CLASSIFIED row is only half-rich. The service column is a
// metadata tag (build-time join above, or the late-metadata decoration in
// [de.schildbach.wallet.service.planMetadataRowDecorations]) that drives
// click-through and the merchant icon — its title/status still read
// "Sending"/"Processing" and must keep transitioning, or a row classified
// while pending sticks there past its lock (the metadata tag is not an
// alternate status feed the way `swap_orders` is). So such a row takes
// ONLY the two surgical status edges below; every value/rate/shape
// re-stamp further down stays off it, and it is never claimed
// SDK-authoritative — same as before the guard split.
val serviceClassified = existing.service != null
if (!serviceClassified) {
sdkAuthoritative += record.txidHex
}

var updated = existing
if (existing.title == resolve(R.string.transaction_row_status_sending) &&
Expand All @@ -610,6 +641,11 @@ internal fun planL1DisplaySync(
) {
updated = updated.copy(statusText = "")
}
if (serviceClassified) {
// Status transitions only (see the guard split above).
if (updated != existing) updates += updated
continue
}

// Re-stamp degenerate carried-over / pre-block rows. A dashj-era or
// event-born row can carry value=0 (attribution not yet written) or a
Expand Down Expand Up @@ -842,17 +878,21 @@ internal fun l1TxUiRecordFromEvent(event: L1TxEvent.Detected, nowMs: Long): L1Tx
* event, so the flip happens the moment the IS lock lands instead of on
* the next Room emission. The event carries only a txid (no direction),
* so the row itself tells us which edges apply. Same never-touch guards
* as the planner: rows with richer semantics (service, gift card, error,
* CoinJoin) are left byte-identical. Returns null when nothing changes.
* Pure — host-testable.
* as the planner: rows with richer semantics (gift card, swap, error,
* CoinJoin) are left byte-identical, while a metadata-supplied `service`
* classification alone does NOT opt a row out — its title/status are the
* plain pending texts and these are exactly the two edges that must keep
* firing on it. Returns null when nothing changes. Pure — host-testable.
*/
internal fun planL1InstantLockRowUpdate(
existing: TxDisplayCacheEntry,
resolve: (Int) -> String
): TxDisplayCacheEntry? {
// Same never-touch set as [planL1DisplaySync]'s update path, swap rows included
// (their title comes from `swap_orders`, not from a lock).
if (existing.hasErrors || existing.service != null || existing.swapStatus != null ||
// (their title comes from `swap_orders`, not from a lock). `service` is
// deliberately absent: a serviced row still takes the status edges (see the
// planner's guard split).
if (existing.hasErrors || existing.swapStatus != null ||
(existing.filterFlags and TxDisplayCacheEntry.FLAG_GIFT_CARD) != 0 ||
(existing.filterFlags and TxDisplayCacheEntry.FLAG_COINJOIN) != 0
) {
Expand Down Expand Up @@ -1755,6 +1795,17 @@ class CutoverUiDataService internal constructor(
* without a mirror keep the pre-existing trust-the-event behavior).
*/
private val resolveOwnedInvolvement: suspend (String) -> Boolean? = { null },
/**
* Presentable tx metadata (memo / service / custom icon) for the given display-hex
* txids, from the app's `transaction_metadata` table — the store
* [de.schildbach.wallet.service.WalletTransactionMetadataProvider] maintains and
* platform metadata sync merges into. Joined onto the rows a sync pass INSERTS
* ([planL1DisplaySync]'s `metadataByTxid`) so a row planned AFTER its metadata
* arrived (restored device: platform sync done, L1 scan still walking) is born
* with its memo instead of never learning it. Default empty for the snapshot tests.
*/
private val resolveMetadata: suspend (Collection<String>) -> Map<String, PresentableTxMetadata> =
{ emptyMap() },
/**
* The engine's instant tx feed ([L1ShadowSyncService.txEvents]) —
* mempool detections and IS locks, consumed by [txPipeline] ahead of
Expand Down Expand Up @@ -1911,6 +1962,7 @@ class CutoverUiDataService internal constructor(
l1SyncStatusService: de.schildbach.wallet.service.L1SyncStatusService,
assetLockKindResolver: AssetLockKindResolver,
sdkTxContactResolver: SdkTxContactResolver,
transactionMetadataDao: de.schildbach.wallet.database.dao.TransactionMetadataDao,
instantSendLockDao: de.schildbach.wallet.database.dao.InstantSendLockDao,
dashPayBackfillGate: DashPayBackfillGate,
dashPaySyncStatus: de.schildbach.wallet.service.DashPaySyncStatus,
Expand All @@ -1928,6 +1980,14 @@ class CutoverUiDataService internal constructor(
resolveContact = { txDisplayHex -> sdkTxContactResolver.contactFor(txDisplayHex) },
resolveWalletNets = { txids -> sdkTxContactResolver.signedNetsFor(txids) },
resolveOwnedInvolvement = { txid -> sdkTxContactResolver.ownedInvolvementFor(txid) },
resolveMetadata = { txids ->
// Chunked: SQLite's IN-clause variable cap is 999. Keys go out as
// display-hex (TxId.toString round-trips the same hex).
txids.map { hex -> TxId.wrap(hex) }
.chunked(500)
.flatMap { chunk -> transactionMetadataDao.loadPresentableMetadata(chunk).entries }
.associate { (id, meta) -> id.toString() to meta }
},
clearContactResolutionCaches = { sdkTxContactResolver.clearNegativeCache() },
txEvents = l1ShadowSyncService.txEvents,
isTxFeedTapActive = { l1ShadowSyncService.isTapActive },
Expand Down Expand Up @@ -3352,13 +3412,35 @@ class CutoverUiDataService internal constructor(
}
}

// Presentable metadata for the rows this pass will INSERT — the build-time
// join. Existing rows are deliberately excluded: their late-arriving
// metadata is decorated by the metadata-change observer
// ([TxDisplayCacheService]), which works off the display row by txid.
// Fail-soft: a failed read inserts the rows bare, and that same observer
// (every launch re-emits the full metadata map) converges them.
val insertTxids = records.mapNotNull { r ->
r.txidHex.takeIf { it !in grouped && it !in existing }
}
val metadataByTxid = if (insertTxids.isEmpty()) {
emptyMap()
} else {
try {
resolveMetadata(insertTxids)
} catch (t: Throwable) {
if (t is CancellationException) throw t
log.warn("presentable metadata read failed; rows insert undecorated this pass", t)
emptyMap()
}
}

val plan = planL1DisplaySync(
records, existing, grouped, resolveString, nowMs(),
incomingFiatCode = fiat?.currencyCode,
incomingFiatValue = fiat?.value,
kindByTxid = kindByTxid,
contactByTxid = contactByTxid,
signedNetByTxid = signedNetByTxid,
metadataByTxid = metadataByTxid,
restampFromDefinitiveRecord = !fromEngineEvent
)
// Claim SDK AUTHORITY over every row this pass planned or verified, so the
Expand Down
Loading
Loading