diff --git a/wallet/src/de/schildbach/wallet/database/dao/TransactionMetadataDao.kt b/wallet/src/de/schildbach/wallet/database/dao/TransactionMetadataDao.kt index a8f7a69422..ec99cfbf0e 100644 --- a/wallet/src/de/schildbach/wallet/database/dao/TransactionMetadataDao.kt +++ b/wallet/src/de/schildbach/wallet/database/dao/TransactionMetadataDao.kt @@ -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> + /** 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): Map + @Query("SELECT * FROM transaction_metadata WHERE timestamp <= :end and timestamp >= :start") fun observeByTimestampRange(start: Long, end: Long): Flow> diff --git a/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt b/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt index 8235ed559b..ba6c5e3d22 100644 --- a/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt +++ b/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt @@ -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(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) @@ -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, + existingByRowId: Map +): List = 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] diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt index 76c6dd12dd..29b9f7ea2b 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt @@ -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 @@ -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( @@ -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 = 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 = 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 @@ -526,6 +539,10 @@ 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), @@ -533,11 +550,11 @@ internal fun planL1DisplaySync( 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, // 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 @@ -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 && @@ -572,7 +590,7 @@ 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" — @@ -580,13 +598,26 @@ internal fun planL1DisplaySync( // (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) && @@ -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 @@ -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 ) { @@ -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) -> Map = + { emptyMap() }, /** * The engine's instant tx feed ([L1ShadowSyncService.txEvents]) — * mempool detections and IS locks, consumed by [txPipeline] ahead of @@ -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, @@ -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 }, @@ -3352,6 +3412,27 @@ 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, @@ -3359,6 +3440,7 @@ class CutoverUiDataService internal constructor( kindByTxid = kindByTxid, contactByTxid = contactByTxid, signedNetByTxid = signedNetByTxid, + metadataByTxid = metadataByTxid, restampFromDefinitiveRecord = !fromEngineEvent ) // Claim SDK AUTHORITY over every row this pass planned or verified, so the diff --git a/wallet/test/de/schildbach/wallet/service/MetadataRowDecorationTest.kt b/wallet/test/de/schildbach/wallet/service/MetadataRowDecorationTest.kt new file mode 100644 index 0000000000..6a177a279d --- /dev/null +++ b/wallet/test/de/schildbach/wallet/service/MetadataRowDecorationTest.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.schildbach.wallet.service + +import de.schildbach.wallet.database.entity.TxDisplayCacheEntry +import de.schildbach.wallet.service.platform.sdk.planL1InstantLockRowUpdate +import de.schildbach.wallet_test.R +import org.dash.wallet.common.data.PresentableTxMetadata +import org.dash.wallet.common.data.TxId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Host-JVM regression tests for the LATE metadata decoration of a home-screen row + * whose transaction the held dashj wallet cannot render. + * + * The bug these pin down (verified on-device from field logs): on a restored + * post-cutover device the display rows are planned by + * [de.schildbach.wallet.service.platform.sdk.CutoverUiDataService] during the L1 scan, + * and platform tx metadata syncs in HOURS later as "SDK fallback rows" + * ([WalletTransactionMetadataProvider]) for txs with no dashj wallet transaction. The + * metadata-change observer's only re-decoration path rebuilt rows from a dashj + * [org.dash.wallet.common.transactions.TransactionWrapper], which never resolves for + * those rows — so "platform metadata merged for : memo=N chars" proved DB arrival + * while the render-layer "row … bound with metadata" log fired ZERO times all session. + * [planMetadataRowDecorations] closes the gap by decorating the display-cache row + * directly, keyed by txid, no wrapper involved. + */ +class MetadataRowDecorationTest { + + private val txHex = "6b1f0d5a11c8ab90b4bfcf1704a9e2d8c07331f5a2f9db6ce85c990c33ca8f01" + private val txId = TxId.wrap(txHex) + private val iconId = TxId.wrap(ByteArray(32) { 0x2a }) + private val now = 1_787_000_000_000L + + /** The row the SDK planner inserted during the restore's L1 scan: no metadata yet. */ + private fun sdkPlannedRow( + comment: String = "", + service: String? = null, + customIconId: String? = null + ) = TxDisplayCacheEntry( + rowId = txHex, + title = "Sent", + valueSatoshis = -96_450_000L, + iconType = TxDisplayCacheEntry.ICON_SENT, + iconBgType = TxDisplayCacheEntry.BG_SENT, + statusText = "", + comment = comment, + transactionAmount = 1, + time = now - 3_600_000, + hasErrors = false, + service = service, + exchangeRateFiatCode = "USD", + exchangeRateFiatValue = 42L, + contactUsername = null, + contactDisplayName = null, + contactAvatarUrl = null, + contactUserId = null, + filterFlags = TxDisplayCacheEntry.FLAG_SENT, + customIconId = customIconId + ) + + private fun metadata( + memo: String = "", + service: String? = null, + customIconId: TxId? = null + ) = PresentableTxMetadata(txId = txId, memo = memo, service = service, customIconId = customIconId) + + @Test + fun lateMetadata_decoratesRowWithoutWrapper() { + val existing = sdkPlannedRow() + val decorated = planMetadataRowDecorations( + mapOf(txHex to metadata(memo = "rent, august", service = "CrowdNode", customIconId = iconId)), + mapOf(txHex to existing) + ).single() + + assertEquals("rent, august", decorated.comment) + assertEquals("CrowdNode", decorated.service) + assertEquals(iconId.toString(), decorated.customIconId) + // Decoration only: the SDK-planned shape survives untouched. + assertEquals(existing.title, decorated.title) + assertEquals(existing.valueSatoshis, decorated.valueSatoshis) + assertEquals(existing.iconType, decorated.iconType) + assertEquals(existing.iconBgType, decorated.iconBgType) + assertEquals(existing.statusText, decorated.statusText) + assertEquals(existing.time, decorated.time) + assertEquals(existing.filterFlags, decorated.filterFlags) + assertEquals(existing.exchangeRateFiatCode, decorated.exchangeRateFiatCode) + assertNull(decorated.contactUserId) + } + + @Test + fun lateMetadata_missingRowPlansNothing() { + val plan = planMetadataRowDecorations( + mapOf(txHex to metadata(memo = "rent, august")), + emptyMap() + ) + assertTrue(plan.isEmpty()) + } + + @Test + fun lateMetadata_settledRowPlansNothing() { + val settled = sdkPlannedRow( + comment = "rent, august", + service = "CrowdNode", + customIconId = iconId.toString() + ) + val plan = planMetadataRowDecorations( + mapOf(txHex to metadata(memo = "rent, august", service = "CrowdNode", customIconId = iconId)), + mapOf(txHex to settled) + ) + assertTrue(plan.isEmpty()) + } + + @Test + fun lateMetadata_neverUnclassifiesService() { + val existing = sdkPlannedRow(service = "CrowdNode", customIconId = iconId.toString()) + val decorated = planMetadataRowDecorations( + mapOf(txHex to metadata(memo = "monthly deposit")), + mapOf(txHex to existing) + ).single() + + assertEquals("monthly deposit", decorated.comment) + assertEquals("CrowdNode", decorated.service) + assertEquals(iconId.toString(), decorated.customIconId) + } + + @Test + fun metadataRemoval_clearsMemoKeepsClassification() { + val existing = sdkPlannedRow(comment = "rent, august", service = "CrowdNode") + val decorated = planMetadataRowDecorations( + mapOf(txHex to null), + mapOf(txHex to existing) + ).single() + + assertEquals("", decorated.comment) + assertEquals("CrowdNode", decorated.service) + } + + @Test + fun lateMetadata_decoratedPendingRowStillSettlesOnLock() { + // CodeRabbit #1545 regression, end to end: metadata classifies the row + // while it is still "Sending"/"Processing", and the IS lock lands AFTER — + // the service tag must not trip the SDK planner's never-touch guard, or + // the row sticks pending forever. + val resolve: (Int) -> String = { id -> "str:$id" } + val pending = sdkPlannedRow().copy( + title = resolve(R.string.transaction_row_status_sending), + statusText = resolve(R.string.transaction_row_status_processing) + ) + val decorated = planMetadataRowDecorations( + mapOf(txHex to metadata(memo = "top-up", service = "CrowdNode")), + mapOf(txHex to pending) + ).single() + assertEquals("CrowdNode", decorated.service) + + val settled = planL1InstantLockRowUpdate(decorated, resolve)!! + assertEquals(resolve(R.string.transaction_row_status_sent), settled.title) + assertEquals("", settled.statusText) + // The decoration itself survives the settle. + assertEquals("CrowdNode", settled.service) + assertEquals("top-up", settled.comment) + } +} diff --git a/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt index 71a4959051..beed4a300a 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt @@ -40,6 +40,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.bitcoinj.core.Coin +import org.dash.wallet.common.data.PresentableTxMetadata +import org.dash.wallet.common.data.TxId import org.dash.wallet.common.data.WalletUIConfig import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -272,6 +274,31 @@ class CutoverUiDataServiceTest { assertTrue(plan.notifyIncoming.isEmpty()) } + @Test + fun syncPlan_insertJoinsPresentableMetadata() { + val r = record(firstByte = 7, net = 1_000_000, context = 1, direction = 0) + val iconId = TxId.wrap(ByteArray(32) { 0x2a }) + val meta = PresentableTxMetadata( + txId = TxId.wrap(displayHex(7)), + memo = "table for two", + service = "CrowdNode", + customIconId = iconId + ) + val plan = planL1DisplaySync( + listOf(r), emptyMap(), emptySet(), resolve, now, + metadataByTxid = mapOf(displayHex(7) to meta) + ) + + val row = plan.inserts.single() + assertEquals("table for two", row.comment) + assertEquals("CrowdNode", row.service) + assertEquals(iconId.toString(), row.customIconId) + // Decoration only — the direction shape still comes from the SDK record. + assertEquals(resolve(R.string.transaction_row_status_received), row.title) + assertEquals(TxDisplayCacheEntry.ICON_RECEIVED, row.iconType) + assertEquals(1_000_000L, row.valueSatoshis) + } + @Test fun syncPlan_groupedTxIsNeverTouched() { val r = record(firstByte = 7) @@ -383,10 +410,9 @@ class CutoverUiDataServiceTest { rowId = displayHex(9), title = sendingTitle, filterFlags = TxDisplayCacheEntry.FLAG_GIFT_CARD or TxDisplayCacheEntry.FLAG_SENT ) - val withService = cacheEntry(rowId = displayHex(9), title = sendingTitle, service = "CrowdNode") val errored = cacheEntry(rowId = displayHex(9), title = sendingTitle, hasErrors = true) - for (entry in listOf(giftCard, withService, errored)) { + for (entry in listOf(giftCard, errored)) { val plan = planL1DisplaySync( listOf(r), mapOf(entry.rowId to entry), emptySet(), resolve, now ) @@ -395,6 +421,62 @@ class CutoverUiDataServiceTest { } } + @Test + fun syncPlan_metadataServicedPendingRowStillSettlesOnLock() { + // CodeRabbit #1545 regression: a row inserted (or late-decorated) while + // pending with a METADATA-supplied service classification must keep taking + // the SDK status transitions — the service tag is decoration, not the + // never-touch rich-row signal, or the row sticks at "Sending"/"Processing" + // past its confirmation. + val pending = record(firstByte = 9, net = -1_000_146, fee = 146, context = 0, direction = 1) + val meta = PresentableTxMetadata( + txId = TxId.wrap(displayHex(9)), + memo = "top-up", + service = "CrowdNode" + ) + val born = planL1DisplaySync( + listOf(pending), emptyMap(), emptySet(), resolve, now, + metadataByTxid = mapOf(displayHex(9) to meta) + ).inserts.single() + assertEquals("CrowdNode", born.service) + assertEquals(resolve(R.string.transaction_row_status_sending), born.title) + + // IS lock lands on the next pass: title settles, the classification stays. + val locked = record(firstByte = 9, net = -1_000_146, fee = 146, context = 1, direction = 1) + val plan = planL1DisplaySync( + listOf(locked), mapOf(born.rowId to born), emptySet(), resolve, now + ) + val settled = plan.updates.single() + assertEquals(resolve(R.string.transaction_row_status_sent), settled.title) + assertEquals("CrowdNode", settled.service) + assertEquals("top-up", settled.comment) + assertEquals(born.valueSatoshis, settled.valueSatoshis) + } + + @Test + fun syncPlan_metadataServicedProcessingRowClearsOnInBlock() { + // Same regression, receive side: metadata-classified while "Processing", + // then a plain in-block record — the secondary status must still clear. + val processing = cacheEntry( + rowId = displayHex(5), + title = resolve(R.string.transaction_row_status_received), + statusText = resolve(R.string.transaction_row_status_processing), + service = "uphold", + filterFlags = TxDisplayCacheEntry.FLAG_RECEIVED + ).copy( + valueSatoshis = 1000L, + iconType = TxDisplayCacheEntry.ICON_RECEIVED, + iconBgType = TxDisplayCacheEntry.BG_RECEIVED + ) + val plan = planL1DisplaySync( + listOf(record(firstByte = 5, net = 1000, context = 2, direction = 0)), + mapOf(processing.rowId to processing), emptySet(), resolve, now + ) + val cleared = plan.updates.single() + assertEquals("", cleared.statusText) + assertEquals("uphold", cleared.service) + } + @Test fun syncPlan_cachedShieldRowRestampedToTransferIcon() { // A shield row cached under the old spec: "Shielded" title but the @@ -592,7 +674,7 @@ class CutoverUiDataServiceTest { ) assertTrue("re-stamped ${resolve(titleRes)}", plan.updates.isEmpty()) } - // The service / gift-card / error / CoinJoin-flag carve-outs stay untouchable + // The gift-card / error / CoinJoin-flag carve-outs stay untouchable // AND are never claimed as SDK-authoritative. val sendingTitle = resolve(R.string.transaction_row_status_sending) val carved = listOf( @@ -600,7 +682,6 @@ class CutoverUiDataServiceTest { rowId = displayHex(9), title = sendingTitle, filterFlags = TxDisplayCacheEntry.FLAG_GIFT_CARD or TxDisplayCacheEntry.FLAG_SENT ), - cacheEntry(rowId = displayHex(9), title = sendingTitle, service = "CrowdNode"), cacheEntry(rowId = displayHex(9), title = sendingTitle, hasErrors = true), cacheEntry( rowId = displayHex(9), title = sendingTitle, @@ -614,6 +695,18 @@ class CutoverUiDataServiceTest { assertTrue(plan.updates.isEmpty()) assertTrue(plan.sdkAuthoritative.isEmpty()) } + // A service-classified row takes ONLY the status edges: the title settles, + // but the definitive value re-stamp stays off it and it is never claimed + // as SDK-authoritative. + val serviced = cacheEntry(rowId = displayHex(9), title = sendingTitle, service = "CrowdNode") + val servicedPlan = planL1DisplaySync( + listOf(r), mapOf(serviced.rowId to serviced), emptySet(), resolve, now + ) + val servicedRow = servicedPlan.updates.single() + assertEquals(resolve(R.string.transaction_row_status_sent), servicedRow.title) + assertEquals(serviced.valueSatoshis, servicedRow.valueSatoshis) + assertEquals(serviced.iconType, servicedRow.iconType) + assertTrue(servicedPlan.sdkAuthoritative.isEmpty()) } @Test @@ -826,6 +919,8 @@ class CutoverUiDataServiceTest { ownedInvolvement: suspend (String) -> Boolean? = { true }, /** Foreign-excluded store nets for the negative-event validation and contact rows. */ walletNets: suspend (Set) -> Map = { emptyMap() }, + /** Presentable metadata store for the build-time row join; default = none known. */ + metadata: Map = emptyMap(), /** MO-995: the bind-retry consultation the bound-wallet wait loop drives. */ retryBind: suspend () -> Unit = {} ) = CutoverUiDataService( @@ -850,6 +945,7 @@ class CutoverUiDataServiceTest { }, resolveOwnedInvolvement = ownedInvolvement, resolveWalletNets = walletNets, + resolveMetadata = { txids -> metadata.filterKeys { it in txids } }, retryBind = retryBind, nowMs = { now } ) @@ -1302,6 +1398,36 @@ class CutoverUiDataServiceTest { assertEquals(listOf(1_000_000L), notified) } + @Test + fun postCutover_insertJoinsMetadataPresentAtBuildTime() = runTest { + // Restored-device ordering: platform metadata synced BEFORE the L1 scan + // reached this tx, so the metadata store already holds the memo when the + // row is planned. The inserted row must be born with it. + val incoming = record(firstByte = 7, net = 1_000_000, context = 1, direction = 0) + val source = FakeSource(records = MutableStateFlow(listOf(incoming))) + val displayDao = mockk(relaxed = true) + coEvery { displayDao.getEntriesByIds(any()) } returns emptyList() + val groupDao = mockk(relaxed = true) + coEvery { groupDao.getGroupsForTxIds(any()) } returns emptyList() + + val service = buildService( + source, configWithState("CUT_OVER"), backgroundScope, + displayDao = displayDao, groupDao = groupDao, + metadata = mapOf( + displayHex(7) to PresentableTxMetadata( + txId = TxId.wrap(displayHex(7)), + memo = "curry night" + ) + ) + ) + service.start() + runCurrent() + + val inserted = slot>() + coVerify { displayDao.insertAll(capture(inserted)) } + assertEquals("curry night", inserted.captured.single().comment) + } + @Test fun postCutover_knownRowsProduceNoWrites() = runTest { val sent = record(firstByte = 9, net = -1_000_146, fee = 146, context = 1, direction = 1) @@ -1439,7 +1565,6 @@ class CutoverUiDataServiceTest { filterFlags = flags ) } - assertNull(planL1InstantLockRowUpdate(pendingLook(TxDisplayCacheEntry.FLAG_SENT, "crowdnode", false), resolve)) assertNull(planL1InstantLockRowUpdate(pendingLook(TxDisplayCacheEntry.FLAG_SENT, null, true), resolve)) assertNull( planL1InstantLockRowUpdate( @@ -1455,6 +1580,24 @@ class CutoverUiDataServiceTest { ) } + @Test + fun isLockPlan_metadataServicedRowStillSettles() { + // CodeRabbit #1545 regression, IS-lock edge: a metadata-supplied service + // classification must not freeze the row — the lock still flips + // "Sending" → "Sent" and clears "Processing", with the tag preserved. + val serviced = cacheEntry( + rowId = displayHex(6), + title = resolve(R.string.transaction_row_status_sending), + statusText = resolve(R.string.transaction_row_status_processing), + service = "crowdnode" + ) + val updated = planL1InstantLockRowUpdate(serviced, resolve)!! + assertEquals(resolve(R.string.transaction_row_status_sent), updated.title) + assertEquals("", updated.statusText) + assertEquals("crowdnode", updated.service) + assertEquals(serviced.valueSatoshis, updated.valueSatoshis) + } + /** A stateful display-cache fake: inserts land in [store], reads see them. */ private fun statefulDisplayDao(store: MutableMap): TxDisplayCacheDao { val dao = mockk(relaxed = true)