diff --git a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountFragment.kt b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountFragment.kt
index 53ccc082eb..66c8c27a87 100644
--- a/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountFragment.kt
+++ b/common/src/main/java/org/dash/wallet/common/ui/enter_amount/EnterAmountFragment.kt
@@ -34,6 +34,7 @@ import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.withStarted
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.dash.wallet.common.money.Coin
@@ -173,6 +174,7 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) {
binding.keyboardView.onKeyboardActionListener = keyboardActionListener
binding.continueBtn.setOnClickListener {
+ if (binding.continueProgress.isVisible) return@setOnClickListener
val dashAmount = binding.amountView.dashAmount
val fiatAmount = binding.amountView.fiatAmount
viewModel.onContinueEvent.value = Pair(dashAmount, fiatAmount)
@@ -189,6 +191,7 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) {
}
viewModel.canContinue.observe(viewLifecycleOwner) { canContinue ->
+ if (continueLoading) return@observe
binding.continueBtn.isEnabled = if (!didAuthorize && requirePinForBalance && !viewModel.blockContinue) {
viewModel.amount.value?.isPositive == true
} else {
@@ -214,6 +217,29 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) {
}
}
+ /**
+ * Show a progress circle on the continue button and DISABLE it — for
+ * hosts whose action runs asynchronously after the tap. The disabled
+ * state is sticky: [canContinue] emissions cannot re-enable the button
+ * while loading (that observer would otherwise flip it back on within
+ * milliseconds).
+ */
+ fun setContinueLoading(loading: Boolean) {
+ continueLoading = loading
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.lifecycle.withStarted {
+ binding.continueProgress.isVisible = loading
+ binding.continueBtn.text = if (loading) "" else getString(R.string.button_continue)
+ // isEnabled alone gives the app's standard disabled look: the
+ // button theme already maps it to `disabledBackgroundColor`.
+ binding.continueBtn.isEnabled = !loading
+ }
+ }
+ }
+
+ /** True while [setContinueLoading] holds the button in its busy state. */
+ private var continueLoading = false
+
fun applyMaxAmount() {
lifecycleScope.launchWhenStarted {
onMaxAmountButtonClick()
diff --git a/common/src/main/res/layout/fragment_enter_amount.xml b/common/src/main/res/layout/fragment_enter_amount.xml
index 1c7d8f1fa9..d8a22daf2a 100644
--- a/common/src/main/res/layout/fragment_enter_amount.xml
+++ b/common/src/main/res/layout/fragment_enter_amount.xml
@@ -128,14 +128,28 @@
android:layout_marginBottom="@dimen/enter_amount_keyboard_spacing"
app:nk_decSeparatorEnabled="true" />
-
+ android:layout_marginHorizontal="15dp">
+
+
+
+
+
diff --git a/wallet/res/values/strings-dashpay.xml b/wallet/res/values/strings-dashpay.xml
index eaaee3d792..5007cffbcc 100644
--- a/wallet/res/values/strings-dashpay.xml
+++ b/wallet/res/values/strings-dashpay.xml
@@ -454,6 +454,7 @@
the asset-lock build can actually select (final, confirmed/InstantSend-
locked coins) do not. -->
You need at least %s spendable Dash for this top-up. Recently received or transferred funds may still be settling.
+ Enter at least %s to buy credits.+ what is username voting?Letters, numbers and hyphens only
diff --git a/wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt b/wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt
index 87f1dea276..fdd13285c1 100644
--- a/wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt
+++ b/wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt
@@ -1129,26 +1129,6 @@ class SendCoinsTaskRunner @Inject constructor(
return sendRequest
}
- fun createAssetLockSendRequest(
- mayEditAmount: Boolean,
- paymentIntent: PaymentIntent,
- signInputs: Boolean,
- forceEnsureMinRequiredFee: Boolean,
- topUpKey: ECKey
- ): SendRequest {
- val wallet = walletData.wallet ?: throw RuntimeException(WALLET_EXCEPTION_MESSAGE)
- Context.propagate(wallet.context)
- val sendRequest = SendRequest.assetLock(wallet.params, topUpKey, paymentIntent.amount.toDashjCoin())
- sendRequest.coinSelector = getCoinSelector()
- sendRequest.useInstantSend = false
- sendRequest.feePerKb = Constants.ECONOMIC_FEE.toDashjCoin()
- sendRequest.ensureMinRequiredFee = forceEnsureMinRequiredFee
- sendRequest.signInputs = signInputs
- val walletBalance = wallet.getBalance(getMaxOutputCoinSelector())
- sendRequest.emptyWallet = mayEditAmount && walletBalance.value == paymentIntent.amount?.value
-
- return sendRequest
- }
@VisibleForTesting
fun createSendRequest(
diff --git a/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt b/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt
index e19ec5ce9d..f449645aa4 100644
--- a/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt
+++ b/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt
@@ -31,7 +31,8 @@ import de.schildbach.wallet.database.entity.DashPayProfile
import de.schildbach.wallet.database.entity.Invitation
import de.schildbach.wallet.database.entity.TopUp
import de.schildbach.wallet.service.DashSystemService
-import de.schildbach.wallet.service.platform.work.TopupIdentityWorker
+import de.schildbach.wallet.service.platform.sdk.SdkTopUpRecoveryService
+import de.schildbach.wallet.service.platform.work.ResumeTopUpsOperation
import de.schildbach.wallet.ui.dashpay.PlatformRepo
import de.schildbach.wallet_test.BuildConfig
import org.bitcoinj.core.Coin
@@ -83,7 +84,7 @@ import androidx.core.net.toUri
/**
* contains topup related functions that are used by:
* 1. [CreateIdentityService] to create an identity
- * 2. [TopupIdentityWorker] to topup an identity
+ * 2. [checkTopUps] to retry/complete legacy top-ups
* 3. [SendInviteWorker] to create Invitations (dynamic link)
*/
interface TopUpRepository {
@@ -174,7 +175,8 @@ class TopUpRepositoryImpl @Inject constructor(
private val dashPayProfileDao: DashPayProfileDao,
private val invitationsDao: InvitationsDao,
private val dashPayConfig: DashPayConfig,
- private val dashSystemService: DashSystemService
+ private val dashSystemService: DashSystemService,
+ private val sdkTopUpRecoveryService: SdkTopUpRecoveryService
) : TopUpRepository {
companion object {
private val log = LoggerFactory.getLogger(TopUpRepositoryImpl::class.java)
@@ -528,38 +530,24 @@ class TopUpRepositoryImpl @Inject constructor(
}
}
- private var checkedPreviousTopUps = false
+ /**
+ * Phase 2/3 (MO-998): the legacy dashj retry loops are DELETED — the
+ * SDK's tracked-lock queue is the only top-up retry system. Uncredited
+ * dashj-era top-ups from before the migration are NOT retried by the
+ * app anymore; they become recoverable again when the SDK gains
+ * chain rediscovery of asset locks (the pending platform change), at
+ * which point they surface on the recovery queue below like any
+ * interrupted SDK top-up. Funds are never lost in the interim — the
+ * locks sit on chain, claimable by this wallet's keys.
+ */
override suspend fun checkTopUps(aesKeyParameter: KeyParameter?) {
- val topUps = topUpsDao.getUnused()
- topUps.forEach { topUp ->
- try {
- val tx = walletDataProvider.wallet!!.getTransaction(topUp.txId)
- val assetLockTx = authExtension.getAssetLockTransaction(tx)
- topUpIdentity(assetLockTx, aesKeyParameter)
- topUpsDao.insert(topUp.copy(creditedAt = System.currentTimeMillis()))
- } catch (e: Exception) {
- // swallow
- }
- }
- // only check once per app start
- if (!checkedPreviousTopUps) {
- log.info("checking all topup transactions")
- authExtension.topupFundingTransactions.forEach { assetLockTx ->
- val topUp = topUpsDao.getByTxId(assetLockTx.txId)
- if (topUp == null || topUp.notUsed()) {
- val identity = topUp?.toUserId ?: identityRepository.blockchainIdentity!!.uniqueIdentifier.toString()
- if (topUp == null) {
- topUpsDao.insert(TopUp(assetLockTx.txId, identity))
- }
- try {
- topUpIdentity(assetLockTx, platformRepo.getWalletEncryptionKey()!!)
- } catch (e: Exception) {
- log.info("problem executing topup for ${assetLockTx.txId}", e)
- }
- }
+ try {
+ if (sdkTopUpRecoveryService.hasPendingTopUpLocks()) {
+ ResumeTopUpsOperation(walletApplication).enqueue()
}
- checkedPreviousTopUps = true
+ } catch (e: Exception) {
+ log.warn("failed to check/enqueue the SDK top-up drain", e)
}
}
diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkAssetLockFundingPreflight.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkAssetLockFundingPreflight.kt
index f4df72af10..f4c732d8ce 100644
--- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkAssetLockFundingPreflight.kt
+++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkAssetLockFundingPreflight.kt
@@ -210,6 +210,31 @@ data class AssetLockFundingEvidence(
val unclassifiedDuffs: Long
)
+/**
+ * COUNT twin of [ELIGIBLE_ASSET_LOCK_DUFFS_SQL] — the number of UTXOs a
+ * fresh asset-lock build can select. Sizes the fee reserve a MAX
+ * ("spend everything") top-up withholds on its one adjusted retry: the fee
+ * is ~148 bytes per INPUT, and this is the exact input population, from the
+ * engine that will do the selecting. (dashj's spendableUtxoCount() is the
+ * wrong ruler here: it counts coins the asset lock can never select —
+ * CoinJoin, other accounts, non-final — and post-cutover it can be stale.)
+ */
+internal val ELIGIBLE_ASSET_LOCK_UTXO_COUNT_SQL = eligibleAssetLockUtxoCountSql(lockCount = 0)
+
+/**
+ * The COUNT with the SAME [lockCount]-parameterized finality term and spine
+ * as [eligibleAssetLockDuffsSql] (args: walletId, then the wire-order txid
+ * blobs) — the two queries must count/sum the SAME population, or the MAX
+ * fee reserve gets sized from a different UTXO set than the one the sum
+ * (and the engine's selection) sees.
+ */
+internal fun eligibleAssetLockUtxoCountSql(lockCount: Int): String =
+ "SELECT COUNT(*) $ASSET_LOCK_TXO_JOINS " +
+ "WHERE t.walletId = ? " +
+ "AND $UNSPENT_SELECTABLE_TERMS " +
+ "AND ${mirrorFinalTermSql(lockCount)} " +
+ "AND $BIP44_ACCOUNT_0_TERM"
+
/**
* Pure coverage predicate for the preflight (host-JVM testable): can
* [eligibleDuffs] of asset-lock-eligible funds cover a lock of
@@ -359,7 +384,13 @@ class SdkAssetLockFundingPreflight internal constructor(
* `null` when unavailable. Production wiring runs the two SQL passes
* against the SDK's Room database.
*/
- private val evidenceQuery: suspend () -> AssetLockFundingEvidence?
+ private val evidenceQuery: suspend () -> AssetLockFundingEvidence?,
+ /**
+ * COUNT twin of [evidenceQuery]'s eligible sum: the eligible-UTXO
+ * population, for sizing a MAX top-up's fee reserve. `null` when
+ * unavailable.
+ */
+ private val eligibleUtxoCountQuery: suspend () -> Int? = { null }
) {
@Inject
constructor(
@@ -386,6 +417,21 @@ class SdkAssetLockFundingPreflight internal constructor(
emptyList()
}
)
+ },
+ eligibleUtxoCountQuery = {
+ queryEligibleAssetLockUtxoCount(
+ sdkService.databaseOrNull(),
+ sdkService.walletManagerOrNull()?.wallets?.value?.keys?.singleOrNull(),
+ // Same lock evidence as evidenceQuery, so the count's
+ // population cannot diverge from the sum's.
+ persistedLockTxidsHex = try {
+ instantSendLockDao.getMostRecentTxIds(MAX_PREFLIGHT_LOCK_TXIDS)
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.warn("persisted IS-lock read failed; UTXO count evaluates without lock evidence", t)
+ emptyList()
+ }
+ )
}
)
@@ -418,6 +464,30 @@ class SdkAssetLockFundingPreflight internal constructor(
* `null` = no evidence either way — treat as fundable (fail open).
* A `false` is logged with the figures for on-device forensics.
*/
+ /**
+ * The number of UTXOs a fresh asset-lock build can select — the input
+ * population whose per-input bytes dominate the L1 fee. `null` = no
+ * evidence (pre-cutover, SDK unavailable, read failure); callers fall
+ * back to not adjusting rather than guessing.
+ */
+ suspend fun eligibleAssetLockUtxoCountOrNull(): Int? {
+ val committed = try {
+ cutoverCommitted()
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.warn("asset-lock funding preflight: cutover state read failed; no UTXO count", t)
+ return null
+ }
+ if (!committed) return null
+ return try {
+ eligibleUtxoCountQuery()
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.warn("asset-lock funding preflight: UTXO count read failed", t)
+ null
+ }
+ }
+
suspend fun canFundAssetLockDuffs(requiredDuffs: Long): Boolean? {
val evidence = assetLockFundingEvidenceOrNull() ?: return null
val verdict = assetLockFundingVerdict(evidence, requiredDuffs)
@@ -458,6 +528,36 @@ class SdkAssetLockFundingPreflight internal constructor(
* rule, coinbase rows are excluded outright (conservative — can
* only under-count, never over-count).
*/
+ /**
+ * COUNT twin of [queryAssetLockFundingEvidence]'s eligible sum — how
+ * many UTXOs the asset-lock coin selection can draw on, counted over
+ * the SAME spine and the SAME persisted-IS-lock finality evidence so
+ * the population cannot diverge from the sum's. `null` when the SDK
+ * database or wallet binding is unavailable.
+ */
+ internal suspend fun queryEligibleAssetLockUtxoCount(
+ database: org.dashfoundation.dashsdk.persistence.DashDatabase?,
+ walletIdHex: String?,
+ persistedLockTxidsHex: List = emptyList()
+ ): Int? {
+ val db = database ?: return null
+ val walletId = walletIdHex?.let { walletIdFromHex(it) } ?: return null
+ val lockBlobs = persistedLockTxidsHex
+ .take(MAX_PREFLIGHT_LOCK_TXIDS)
+ .mapNotNull { hexToBytesOrNull(it.lowercase())?.takeIf { b -> b.size == 32 }?.reversedArray() }
+ return withContext(Dispatchers.IO) {
+ val args = ArrayList(1 + lockBlobs.size)
+ args.add(walletId)
+ args.addAll(lockBlobs)
+ db.openHelper.readableDatabase.query(
+ androidx.sqlite.db.SimpleSQLiteQuery(
+ eligibleAssetLockUtxoCountSql(lockBlobs.size),
+ args.toTypedArray()
+ )
+ ).use { cursor -> if (cursor.moveToFirst()) cursor.getInt(0) else 0 }
+ }
+ }
+
internal suspend fun queryAssetLockFundingEvidence(
database: org.dashfoundation.dashsdk.persistence.DashDatabase?,
walletIdHex: String?,
diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkDashPayWrites.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkDashPayWrites.kt
index 0c902394ca..e7f31c7fb8 100644
--- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkDashPayWrites.kt
+++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkDashPayWrites.kt
@@ -99,6 +99,18 @@ sealed class SdkWriteResult {
* Kept as a top-level pure function so the table is unit-testable on the
* host JVM without any native or Android dependency.
*/
+/**
+ * [classifyBroadcastFailure] reasons for the two PRE-BROADCAST funding
+ * shortfalls that are retryable with a smaller amount (nothing submitted,
+ * selection released). Named so retry logic — e.g. a MAX top-up's one-shot
+ * fee-adjusted retry — matches the classifier's own verdict instead of
+ * re-matching raw engine messages that differ per build path.
+ */
+internal const val REASON_PRE_BROADCAST_BUILD_SHORTFALL =
+ "pre-broadcast build failure (insufficient funds / coin selection)"
+internal const val REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION =
+ "pre-broadcast asset-lock coin-selection failure"
+
internal fun classifyBroadcastFailure(t: Throwable): SdkWriteResult = when {
t is DashSdkError.InvalidParameter ||
t is DashSdkError.InvalidState ||
@@ -143,6 +155,20 @@ internal fun classifyBroadcastFailure(t: Throwable): SdkWriteResult = w
// replaces the auth window and gives this a typed error.
t.message?.contains("User not authenticated") == true ->
SdkWriteResult.NotBroadcast("signing failure (pre-broadcast): Keystore auth window expired", t)
+ // TYPED funding shortfalls — checked BEFORE the message arms because
+ // engine message text drifts across AAR lines while the type cannot.
+ // CoreInsufficientFunds (FFI 22) is the atomic Core selection;
+ // AssetLockInsufficientFunds (FFI 29) is the asset-lock coin selection
+ // (asset_lock/build.rs map_builder_error promotes every builder
+ // shortfall shape to it, including the zero-candidate NoUtxosAvailable).
+ // Both are raised while BUILDING, strictly pre-broadcast, nothing
+ // submitted and the selection released — retryable with a smaller
+ // amount. The message arms below stay as the fallback for AAR lines
+ // that still surface these as WalletOperation strings.
+ t is DashSdkError.PlatformWallet.CoreInsufficientFunds ->
+ SdkWriteResult.NotBroadcast(REASON_PRE_BROADCAST_BUILD_SHORTFALL, t)
+ t is DashSdkError.PlatformWallet.AssetLockInsufficientFunds ->
+ SdkWriteResult.NotBroadcast(REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION, t)
// Coin selection / insufficient funds happens during transaction BUILDING,
// strictly before any broadcast — nothing was submitted. Surfaced as a
// WalletOperation error carrying the reason in the message (observed live:
@@ -155,7 +181,7 @@ internal fun classifyBroadcastFailure(t: Throwable): SdkWriteResult = w
m.contains("transaction build failed") ||
m.contains("set_funding failed")
} == true ->
- SdkWriteResult.NotBroadcast("pre-broadcast build failure (insufficient funds / coin selection)", t)
+ SdkWriteResult.NotBroadcast(REASON_PRE_BROADCAST_BUILD_SHORTFALL, t)
// Shielded note selection (rs-platform-wallet note_selection.rs) runs
// strictly BEFORE proof generation or broadcast — nothing was submitted
// and the selected notes are released. Surfaced as a WalletOperation
@@ -182,7 +208,7 @@ internal fun classifyBroadcastFailure(t: Throwable): SdkWriteResult = w
// real shape. Message-matched until the SDK exposes typed errors.
// Retryable with a smaller amount.
t.message?.contains("asset lock coin selection is short") == true ->
- SdkWriteResult.NotBroadcast("pre-broadcast asset-lock coin-selection failure", t)
+ SdkWriteResult.NotBroadcast(REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION, t)
// The SDK's SPV client wasn't running when broadcast was attempted, so the
// tx never left the device (observed live: the interim shield pipeline
// broadcasts via the shadow SPV, which our recovery paths stop/reset — the
diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt
new file mode 100644
index 0000000000..92927bed61
--- /dev/null
+++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt
@@ -0,0 +1,328 @@
+/*
+ * 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.platform.sdk
+
+import de.schildbach.wallet.database.entity.BlockchainIdentityConfig
+import kotlinx.coroutines.CancellationException
+import org.bitcoinj.core.Utils
+import org.dashfoundation.dashsdk.errors.DashSdkError
+import org.dashfoundation.dashsdk.wallet.TrackedAssetLock
+import org.dashj.platform.dpp.identifier.Identifier
+import org.slf4j.LoggerFactory
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Wire-order (little-endian) txid bytes → the display-order hex logs/UI use. */
+internal fun ByteArray.toTxidHex(): String = Utils.HEX.encode(reversedArray())
+
+/**
+ * Whether a failed resume proves the lock's credits ALREADY landed — a
+ * terminal outcome that must never be retried (WorkManager would otherwise
+ * back off forever on a lock nothing can advance).
+ *
+ * Two shapes, because Platform's own rejection does NOT arrive as the SDK's
+ * typed error: the local tombstone check throws
+ * [DashSdkError.PlatformWallet.AssetLockAlreadyConsumed], while a lock
+ * consumed Platform-side but not yet marked locally comes back as a Generic
+ * protocol error reading "…output N already completely used" (observed live
+ * 2026-08-04 after a mid-top-up process death; the same wording the legacy
+ * dashj path matched on). Message-matched until the SDK reconciles the
+ * local row (platform ask on MO-998), and matched down the cause chain
+ * because the JNI wraps it.
+ */
+internal fun isAlreadyConsumed(t: Throwable): Boolean {
+ if (t is DashSdkError.PlatformWallet.AssetLockAlreadyConsumed) return true
+ var cause: Throwable? = t
+ var hops = 0
+ while (cause != null && hops < 8) {
+ if (cause.message?.contains("already completely used", ignoreCase = true) == true) return true
+ cause = cause.cause
+ hops++
+ }
+ return false
+}
+
+// ── Source seam ───────────────────────────────────────────────────────
+
+/**
+ * Seam over the SDK's tracked-lock recovery surface, so the drain
+ * orchestration in [SdkTopUpRecoveryService] is host-JVM unit-testable —
+ * the real calls need `libdash_sdk`.
+ */
+interface SdkTopUpRecoverySource {
+ /** Same contract as [SdkDashPayWriteSource.boundWalletIdOrNull]. */
+ suspend fun boundWalletIdOrNull(): String?
+
+ /**
+ * The Rust-authoritative tracked locks eligible for generic identity
+ * recovery (`PlatformWalletManager.trackedIdentityRecoveryAssetLocks`):
+ * funding types registration/top-up/top-up-not-bound, statuses
+ * Built…ChainLocked. Consumed rows are never offered.
+ */
+ suspend fun trackedRecoveryLocks(walletIdHex: String): List
+
+ /**
+ * Resume [lock] from its exact persisted outpoint
+ * (`IdentityCredits.resumeTopUpWithExistingAssetLock`) — Rust owns
+ * rebroadcast, proof acquisition, and consumption; no new funding
+ * transaction is ever built. Returns the post-transition credit
+ * balance; throws on failure (including the terminal
+ * [DashSdkError.PlatformWallet.AssetLockAlreadyConsumed]).
+ */
+ suspend fun resumeTopUp(walletIdHex: String, identityId: ByteArray, lock: TrackedAssetLock): Long
+}
+
+/** Production [SdkTopUpRecoverySource]: boots the SDK on demand. */
+internal class DashSdkTopUpRecoverySource(
+ private val service: DashSdkService
+) : SdkTopUpRecoverySource {
+
+ private suspend fun manager(): org.dashfoundation.dashsdk.wallet.PlatformWalletManager {
+ service.ensureStarted()
+ return checkNotNull(service.walletManagerOrNull()) {
+ "SDK wallet manager missing after ensureStarted()"
+ }
+ }
+
+ override suspend fun boundWalletIdOrNull(): String? =
+ manager().wallets.value.keys.singleOrNull()
+
+ override suspend fun trackedRecoveryLocks(walletIdHex: String): List =
+ manager().trackedIdentityRecoveryAssetLocks(Utils.HEX.decode(walletIdHex))
+
+ override suspend fun resumeTopUp(
+ walletIdHex: String,
+ identityId: ByteArray,
+ lock: TrackedAssetLock
+ ): Long {
+ val manager = manager()
+ val wallet = checkNotNull(manager.wallets.value[walletIdHex]) { "SDK wallet not loaded" }
+ return manager.identityCredits.resumeTopUpWithExistingAssetLock(
+ walletHandle = wallet.handle,
+ identityId = identityId,
+ lock = lock,
+ coreSignerHandle = manager.mnemonicResolverHandle
+ )
+ }
+}
+
+// ── Drain report ──────────────────────────────────────────────────────
+
+/**
+ * One [SdkTopUpRecoveryService.drainPendingTopUps] pass over the SDK's
+ * tracked top-up locks. [pending] is the count of top-up locks the
+ * recovery surface offered; [resumed] completed their IdentityTopUp
+ * transition this pass; [alreadyConsumed] were rejected as already
+ * consumed Platform-side (terminal — retrying cannot help); [failed] hit
+ * a retryable error; [surfaceUnavailable] means the locks could not even
+ * be enumerated.
+ */
+data class TopUpDrainReport(
+ val pending: Int,
+ val resumed: Int,
+ val alreadyConsumed: Int,
+ val failed: Int,
+ val surfaceUnavailable: Boolean = false
+) {
+ /** Another pass can plausibly make progress — the worker should retry. */
+ val retryNeeded: Boolean get() = surfaceUnavailable || failed > 0
+
+ companion object {
+ val NOTHING_TO_DO = TopUpDrainReport(0, 0, 0, 0)
+ val UNAVAILABLE = TopUpDrainReport(0, 0, 0, 0, surfaceUnavailable = true)
+ }
+}
+
+// ── The recovery service ──────────────────────────────────────────────
+
+/**
+ * Restart-surviving completion of interrupted SDK identity top-ups
+ * (#1520 item 3 / MO-998, Phase B). [SdkTransparentTopUp] owns the
+ * user-facing top-up (its resume gate handles the in-process retry when
+ * the user taps again); THIS service is the background half: a
+ * [de.schildbach.wallet.service.platform.work.ResumeTopUpsWorker] drain
+ * pass completes any tracked top-up lock left behind by a crash or an
+ * ambiguous outcome, without the user having to re-enter the flow. The
+ * SDK's tracked-lock table is the queue — the worker carries no payload.
+ */
+@Singleton
+class SdkTopUpRecoveryService internal constructor(
+ private val source: SdkTopUpRecoverySource,
+ /**
+ * The bound identity's 32-byte id, or null when the wallet has no
+ * registered identity. Injected so the orchestration is testable
+ * without the identity database.
+ */
+ private val identityIdBytes: suspend () -> ByteArray?,
+ /**
+ * Whether the SDK runtime is ALREADY up — the no-boot guard for
+ * [hasPendingTopUpLocks]. Default false (never boot from a probe).
+ */
+ private val sdkIsStarted: () -> Boolean = { false }
+) {
+ @Inject
+ constructor(
+ sdkService: DashSdkService,
+ blockchainIdentityConfig: BlockchainIdentityConfig
+ ) : this(
+ source = DashSdkTopUpRecoverySource(sdkService),
+ identityIdBytes = {
+ blockchainIdentityConfig.get(BlockchainIdentityConfig.IDENTITY_ID)
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { Identifier.from(it).toBuffer() }
+ },
+ sdkIsStarted = { sdkService.isStarted }
+ )
+
+ /**
+ * One drain pass over the SDK's tracked top-up locks. Enumerates
+ * `trackedIdentityRecoveryAssetLocks`, filters to the resumable
+ * top-up funding types (IDENTITY_TOP_UP / IDENTITY_TOP_UP_NOT_BOUND —
+ * registration locks belong to the registration recovery flow), and
+ * resumes each from its exact persisted outpoint. Rust owns
+ * rebroadcast/proof/consumption, and consumed locks vanish from the
+ * surface, so the pass is idempotent — a crash mid-drain or a double
+ * enqueue is harmless.
+ *
+ * No flag gate and no L1 funding gate: tracked locks only exist
+ * because an SDK top-up ran, and resume never builds a new funding
+ * transaction — gating recovery would strand reserved funds. Never
+ * throws (short of cancellation): every failure lands in the report
+ * so the worker can decide success vs retry.
+ */
+ suspend fun drainPendingTopUps(): TopUpDrainReport {
+ val walletIdHex = try {
+ source.boundWalletIdOrNull()
+ ?: return TopUpDrainReport.NOTHING_TO_DO.also {
+ log.info("drain: app wallet not bound to the SDK; no locks to resume")
+ }
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.warn("drain: SDK bootstrap/bind lookup failed", t)
+ return TopUpDrainReport.UNAVAILABLE
+ }
+ val locks = try {
+ source.trackedRecoveryLocks(walletIdHex).filter { it.isResumableTopUp() }
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.warn("drain: could not enumerate tracked locks", t)
+ return TopUpDrainReport.UNAVAILABLE
+ }
+ if (locks.isEmpty()) return TopUpDrainReport.NOTHING_TO_DO
+
+ val identityId = try {
+ identityIdBytes()?.takeIf { it.size == 32 }
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ null
+ }
+ if (identityId == null) {
+ // Locks exist but there is no identity to credit — an odd state
+ // (top-ups require an identity) worth retrying, not dropping.
+ log.warn("drain: {} pending top-up lock(s) but no 32-byte identity id", locks.size)
+ return TopUpDrainReport(pending = locks.size, resumed = 0, alreadyConsumed = 0, failed = locks.size)
+ }
+
+ var resumed = 0
+ var alreadyConsumed = 0
+ var failed = 0
+ for (lock in locks) {
+ try {
+ val balance = source.resumeTopUp(walletIdHex, identityId, lock)
+ resumed++
+ log.info(
+ "drain: resumed top-up lock {}:{} — new credit balance {}",
+ lock.outpointTxid.toTxidHex(), lock.outpointVout, balance
+ )
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ if (isAlreadyConsumed(t)) {
+ // Terminal: the lock was burned by an earlier successful
+ // top-up; retrying can never help.
+ alreadyConsumed++
+ log.info("drain: lock {}:{} already consumed", lock.outpointTxid.toTxidHex(), lock.outpointVout)
+ } else {
+ failed++
+ log.warn("drain: resume failed for lock {}:{}", lock.outpointTxid.toTxidHex(), lock.outpointVout, t)
+ }
+ }
+ }
+ return TopUpDrainReport(locks.size, resumed, alreadyConsumed, failed)
+ }
+
+ /**
+ * Whether the SDK currently tracks any resumable top-up locks — the
+ * `checkTopUps` trigger predicate. Deliberately NO-BOOT: when the SDK
+ * is not already running this returns false WITHOUT starting it
+ * ([sdkIsStarted]) — a periodic sync sweep must never boot the SDK
+ * stack for users who never used it. The DURABLE recovery path is the
+ * WorkManager job enqueued at failure time, which survives app
+ * restarts and is allowed to boot the SDK. Contained: false when
+ * unreadable.
+ */
+ suspend fun hasPendingTopUpLocks(): Boolean = try {
+ if (!sdkIsStarted()) {
+ false
+ } else {
+ val walletIdHex = source.boundWalletIdOrNull()
+ walletIdHex != null && source.trackedRecoveryLocks(walletIdHex).any { it.isResumableTopUp() }
+ }
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.warn("failed to check for pending top-up locks", t)
+ false
+ }
+
+ /**
+ * Whether the SDK top-up funded by the transaction with [txDisplayHex]
+ * is still PENDING (its lock sits in the recovery surface awaiting the
+ * credit transfer). False = no such pending lock — for a transaction
+ * known to be an SDK top-up that means CREDITED. Null when unknowable
+ * (SDK not running / surface unreadable). No-boot, read-only — safe
+ * from UI screens.
+ *
+ * RESTORE CAVEAT: after a phrase restore the tracked-lock table starts
+ * empty, so an actually-unclaimed top-up also reads "no pending lock"
+ * here. Display-only signal — never gate a spend or a retry on it.
+ * Chain rediscovery of tracked locks (pending platform change) will
+ * make the restored table truthful.
+ */
+ suspend fun isTopUpPending(txDisplayHex: String): Boolean? = try {
+ if (!sdkIsStarted()) {
+ null
+ } else {
+ val walletIdHex = source.boundWalletIdOrNull() ?: return null
+ val wanted = txDisplayHex.lowercase()
+ source.trackedRecoveryLocks(walletIdHex).any {
+ it.isResumableTopUp() && it.outpointTxid.toTxidHex() == wanted
+ }
+ }
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.warn("failed to check pending state for top-up {}", txDisplayHex, t)
+ null
+ }
+
+ private fun TrackedAssetLock.isResumableTopUp(): Boolean =
+ fundingType == TrackedAssetLock.FundingType.IDENTITY_TOP_UP ||
+ fundingType == TrackedAssetLock.FundingType.IDENTITY_TOP_UP_NOT_BOUND
+
+ companion object {
+ private val log = LoggerFactory.getLogger(SdkTopUpRecoveryService::class.java)
+ }
+}
diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTransparentTopUp.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTransparentTopUp.kt
index 8b22db9573..4453a3d08d 100644
--- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTransparentTopUp.kt
+++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTransparentTopUp.kt
@@ -234,7 +234,7 @@ internal class DashSdkTransparentTopUpSource(
* TRANSPARENT-funded identity TOP-UP ("Buy Credits") — the post-cutover
* replacement for the dashj asset-lock funding path in
* [de.schildbach.wallet.ui.send.BuyCreditsFragment] /
- * [de.schildbach.wallet.service.platform.work.TopupIdentityWorker]. Once the
+ * the deleted legacy TopupIdentityWorker. Once the
* cutover is committed the dashj L1 engine is HELD (0 UTXOs), so building the
* top-up asset lock with dashj fails `InsufficientMoneyException` — the funds
* live in the SDK. This routes top-up funding through the SDK's
@@ -403,7 +403,17 @@ class SdkTransparentTopUp internal constructor(
* [SdkWriteResult] three-valued contract holds
* ([SdkWriteResult.Ambiguous] is never retried by anyone).
*/
- suspend fun topUpTransparent(identityIdBase58: String, amountDuffs: Long): SdkWriteResult {
+ suspend fun topUpTransparent(
+ identityIdBase58: String,
+ amountDuffs: Long,
+ /**
+ * Internal: the outpoint of a tracked lock the resume gate must
+ * IGNORE — set on the single self-retry taken after that lock was
+ * rejected as already-consumed (see the catch below). Non-null also
+ * means "this is the retry", so it can never loop.
+ */
+ skipLockOutpoint: Pair? = null
+ ): SdkWriteResult {
// Fail closed unless the cutover is committed — pre-cutover this path
// must submit nothing (the dashj path owns funding then).
val committed = try {
@@ -448,6 +458,15 @@ class SdkTransparentTopUp internal constructor(
// unresolved lock exists would select DIFFERENT UTXOs = DOUBLE-PAY.
val existingLock = try {
source.unresolvedTopUpAssetLock(walletId, ref.registrationIndex)
+ // Drop a lock Platform already rejected as consumed on this
+ // call's first pass: it is stale bookkeeping, not a resumable
+ // candidate, and re-picking it would fail forever (the SDK
+ // never marks it consumed locally — platform ask on MO-998).
+ ?.takeUnless { lock ->
+ skipLockOutpoint?.let { (txid, vout) ->
+ lock.outpointTxid.contentEquals(txid) && lock.outpointVout == vout
+ } == true
+ }
} catch (t: Throwable) {
if (t is CancellationException) throw t
// A failed recovery lookup cannot prove no lock exists — refuse the
@@ -475,6 +494,28 @@ class SdkTransparentTopUp internal constructor(
}
} catch (t: Throwable) {
if (t is CancellationException) throw t
+ // A RESUME that Platform rejects as already-consumed is NOT
+ // ambiguous: those credits provably landed on an earlier attempt.
+ // The SDK keeps the lock in its tracked list regardless (nothing
+ // marks it consumed locally — platform ask on MO-998), so the
+ // resume gate would keep picking this dead lock and every future
+ // purchase would fail. Treat it as "this stale lock is not a
+ // resumable candidate" and immediately retry the pipeline ONCE,
+ // which then takes the fresh-build branch. Safe: the rejection
+ // proves the lock's outputs are spent, so no double-pay is
+ // possible, and the flag stops it from ever looping.
+ if (existingLock != null && isAlreadyConsumed(t) && skipLockOutpoint == null) {
+ log.warn(
+ "resume hit an already-consumed lock at index {} (its credits landed earlier); " +
+ "ignoring that stale tracked lock and building fresh",
+ ref.registrationIndex
+ )
+ return topUpTransparent(
+ identityIdBase58,
+ amountDuffs,
+ skipLockOutpoint = existingLock.outpointTxid to existingLock.outpointVout
+ )
+ }
return when (val classified = classifyBroadcastFailure(t)) {
is SdkWriteResult.NotBroadcast -> {
log.warn("transparent identity top-up rejected pre-broadcast", t)
diff --git a/wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpOperation.kt b/wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpOperation.kt
new file mode 100644
index 0000000000..7e77f045bb
--- /dev/null
+++ b/wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpOperation.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.platform.work
+
+import android.app.Application
+import androidx.lifecycle.LiveData
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+
+/**
+ * Enqueues and observes the ONE in-flight Buy Credits purchase
+ * ([PerformTopUpWorker]). A single unique-work name with
+ * [ExistingWorkPolicy.KEEP]: a double tap (or re-entering the screen while
+ * a purchase runs) attaches to the existing run instead of buying twice.
+ * The screen drives its progress/success/failure UI from [status].
+ */
+class PerformTopUpOperation(private val application: Application) {
+ companion object {
+ const val WORK_NAME = "PerformTopUpWorker"
+
+ /** Live status of the unique purchase work (empty until first use). */
+ fun status(application: Application): LiveData> =
+ WorkManager.getInstance(application)
+ .getWorkInfosForUniqueWorkLiveData(WORK_NAME)
+ }
+
+ fun enqueue(amountDuffs: Long, isMaxSpend: Boolean = false) {
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(
+ workDataOf(
+ PerformTopUpWorker.KEY_AMOUNT_DUFFS to amountDuffs,
+ PerformTopUpWorker.KEY_IS_MAX_SPEND to isMaxSpend
+ )
+ )
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .build()
+ WorkManager.getInstance(application)
+ .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP, request)
+ }
+}
diff --git a/wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpWorker.kt b/wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpWorker.kt
new file mode 100644
index 0000000000..355e828955
--- /dev/null
+++ b/wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpWorker.kt
@@ -0,0 +1,198 @@
+/*
+ * 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.platform.work
+
+import android.app.Application
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import de.schildbach.wallet.database.entity.BlockchainIdentityConfig
+import de.schildbach.wallet.service.platform.sdk.SdkTransparentTopUp
+import de.schildbach.wallet.service.platform.sdk.SdkWriteResult
+import de.schildbach.wallet.service.platform.sdk.REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION
+import de.schildbach.wallet.service.platform.sdk.REASON_PRE_BROADCAST_BUILD_SHORTFALL
+import de.schildbach.wallet.service.platform.sdk.SdkAssetLockFundingPreflight
+import de.schildbach.wallet.service.work.BaseWorker
+import de.schildbach.wallet.ui.shielded.assetLockMaxFeeReserve
+import kotlinx.coroutines.CancellationException
+import org.slf4j.LoggerFactory
+
+/**
+ * Runs ONE user-initiated Buy Credits top-up through the SDK
+ * ([SdkTransparentTopUp]), detached from the screen's lifecycle — a lock
+ * screen, rotation, or process death cannot cancel the purchase mid-flight
+ * (the old dashj flow had this via TopupIdentityWorker; this is its
+ * SDK-only successor). Input is the AMOUNT ONLY: no wallet password and no
+ * transaction id are stored in WorkManager's database — the SDK signs via
+ * its own key resolver.
+ *
+ * Funds safety on reruns: if the process dies mid-call, WorkManager reruns
+ * this worker; [SdkTransparentTopUp]'s resume gate then matches the
+ * already-broadcast lock (by the identity's registration index) and
+ * completes IT instead of building a second one — no double pay. This
+ * worker itself never returns retry: a NotBroadcast outcome is the user's
+ * to retry, and an Ambiguous outcome must never be blindly re-run — it is
+ * handed to [ResumeTopUpsWorker], which resumes only the tracked lock.
+ */
+@HiltWorker
+class PerformTopUpWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted parameters: WorkerParameters,
+ private val sdkTransparentTopUp: SdkTransparentTopUp,
+ private val blockchainIdentityConfig: BlockchainIdentityConfig,
+ private val assetLockFundingPreflight: SdkAssetLockFundingPreflight
+) : BaseWorker(context, parameters) {
+ companion object {
+ private val log = LoggerFactory.getLogger(PerformTopUpWorker::class.java)
+ const val KEY_AMOUNT_DUFFS = "PerformTopUpWorker.AMOUNT_DUFFS"
+
+ /** The purchase is a MAX ("spend everything") — see the class doc. */
+ const val KEY_IS_MAX_SPEND = "PerformTopUpWorker.IS_MAX_SPEND"
+ const val KEY_NEW_BALANCE = "PerformTopUpWorker.NEW_BALANCE"
+ const val KEY_AMBIGUOUS = "PerformTopUpWorker.AMBIGUOUS"
+
+ /** Progress marker: the SDK call that does the actual work has begun. */
+ const val KEY_SDK_CALL_STARTED = "PerformTopUpWorker.SDK_CALL_STARTED"
+
+ /**
+ * Platform's minimum for an IdentityTopUp asset lock, in duffs:
+ * identity_topup_base_cost (500) + the 50,000-duff processing floor —
+ * the same figure the SDK FFI enforces as MIN_TOP_UP_DUFFS. A MAX
+ * retry adjusted below this would broadcast a lock Core accepts but
+ * Platform deterministically rejects, stranding the balance.
+ */
+ const val PLATFORM_TOP_UP_FLOOR_DUFFS = 50_500L
+ }
+
+ override suspend fun doWorkWithBaseProgress(): Result {
+ val amountDuffs = inputData.getLong(KEY_AMOUNT_DUFFS, -1L)
+ if (amountDuffs <= 0L) {
+ return Result.failure(workDataOf(KEY_ERROR_MESSAGE to "missing or invalid amount"))
+ }
+ val identityId = blockchainIdentityConfig.get(BlockchainIdentityConfig.IDENTITY_ID)
+ if (identityId.isNullOrEmpty()) {
+ return Result.failure(workDataOf(KEY_ERROR_MESSAGE to "no identity to top up"))
+ }
+
+ val isMaxSpend = inputData.getBoolean(KEY_IS_MAX_SPEND, false)
+ // What actually went out — the adjusted retry overwrites this, so the
+ // success log names the sent amount, not the requested one.
+ var sentDuffs = amountDuffs
+
+ var result = try {
+ // Tell the UI the hand-off is complete: the purchase is now the
+ // SDK's (and this worker's) responsibility, not the screen's.
+ setProgress(workDataOf(KEY_SDK_CALL_STARTED to true))
+ sdkTransparentTopUp.topUp(identityId, amountDuffs)
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.error("top-up threw unexpectedly", t)
+ SdkWriteResult.Ambiguous(t)
+ }
+
+ // MAX-spend fee convergence — the shielded Internal Transfer rule
+ // (ShieldedTransferExecutor.submit), applied to the top-up asset
+ // lock: the exact L1 fee is unknowable app-side, so a MAX purchase
+ // submits the FULL balance first and, when that fails the
+ // provably-pre-broadcast coin selection (nothing submitted, the
+ // selection released), retries ONCE with an ESTIMATED fee reserve
+ // withheld, sized from the wallet's spendable UTXO count. An
+ // over-reserve is lossless — the asset-lock builder returns the
+ // excess as change. One shot only: the retry result never
+ // re-adjusts. The adjusted amount must stay above Platform's
+ // top-up floor, or the retry would strand a lock Platform rejects.
+ // Match the CLASSIFIER's verdict, not a raw engine message: the
+ // top-up build surfaces its shortfall as key-wallet's builder text
+ // ("Coin selection error: Insufficient funds…"), which
+ // classifyBroadcastFailure already folds — together with the
+ // shielded path's "asset lock coin selection is short" shape — into
+ // these two named, provably-pre-broadcast, retryable reasons.
+ // (Matching only the shielded message here is the bug that made the
+ // first live MAX test fail without ever retrying.)
+ val first = result
+ val retryableShortfall = first is SdkWriteResult.NotBroadcast &&
+ (
+ first.reason == REASON_PRE_BROADCAST_BUILD_SHORTFALL ||
+ first.reason == REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION
+ )
+ if (isMaxSpend && retryableShortfall) {
+ // The input population comes from the SDK's OWN eligible-UTXO
+ // query — the same table its coin selection reads — not dashj's
+ // spendableUtxoCount(): dashj counts coins the asset lock can
+ // never select (CoinJoin, other accounts, non-final) and can be
+ // stale post-cutover. Null = no evidence; adjust nothing.
+ val utxoCount = assetLockFundingPreflight.eligibleAssetLockUtxoCountOrNull()
+ if (utxoCount == null) {
+ log.warn("max top-up: eligible UTXO count unavailable — not auto-adjusting")
+ }
+ val reserve = utxoCount?.let(::assetLockMaxFeeReserve)
+ val adjusted = reserve?.let { amountDuffs - it.duffs }
+ // The FFI floor is INCLUSIVE (`amount < MIN_TOP_UP_DUFFS` rejects),
+ // so exactly 50,500 duffs is a valid retry.
+ if (adjusted != null && adjusted >= PLATFORM_TOP_UP_FLOOR_DUFFS) {
+ sentDuffs = adjusted
+ log.info(
+ "max top-up auto-adjusting for the L1 asset-lock fee: requested {} duffs, " +
+ "reserve {} duffs ({} UTXOs), retrying once with {}",
+ amountDuffs,
+ reserve.duffs,
+ utxoCount,
+ adjusted
+ )
+ result = try {
+ sdkTransparentTopUp.topUp(identityId, adjusted)
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ log.error("adjusted max top-up threw unexpectedly", t)
+ SdkWriteResult.Ambiguous(t)
+ }
+ } else if (adjusted != null) {
+ log.warn(
+ "max top-up not auto-adjusting: {} duffs after the fee reserve is " +
+ "below Platform's {}-duff top-up floor",
+ adjusted,
+ PLATFORM_TOP_UP_FLOOR_DUFFS
+ )
+ }
+ }
+ return when (result) {
+ is SdkWriteResult.Broadcast -> {
+ log.info("top-up of {} duffs credited; new balance {}", sentDuffs, result.value)
+ Result.success(workDataOf(KEY_NEW_BALANCE to result.value))
+ }
+ is SdkWriteResult.NotBroadcast -> {
+ log.warn("top-up not sent: {}", result.reason)
+ Result.failure(workDataOf(KEY_ERROR_MESSAGE to result.reason))
+ }
+ is SdkWriteResult.Ambiguous -> {
+ // The lock, if broadcast, is Rust-tracked — the recovery
+ // worker completes it; never re-run the purchase itself.
+ ResumeTopUpsOperation(applicationContext as Application).enqueue()
+ log.error("top-up outcome unconfirmed; recovery worker enqueued", result.cause)
+ Result.failure(
+ workDataOf(
+ KEY_ERROR_MESSAGE to (result.cause.message ?: "top-up outcome unconfirmed"),
+ KEY_AMBIGUOUS to true
+ )
+ )
+ }
+ }
+ }
+}
diff --git a/wallet/src/de/schildbach/wallet/service/platform/work/ResumeTopUpsOperation.kt b/wallet/src/de/schildbach/wallet/service/platform/work/ResumeTopUpsOperation.kt
new file mode 100644
index 0000000000..bd86e7e960
--- /dev/null
+++ b/wallet/src/de/schildbach/wallet/service/platform/work/ResumeTopUpsOperation.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.platform.work
+
+import android.app.Application
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import org.slf4j.LoggerFactory
+import java.util.concurrent.TimeUnit
+
+/**
+ * Enqueues the ONE [ResumeTopUpsWorker] drain instance. A single fixed
+ * unique-work name + [ExistingWorkPolicy.KEEP] — there is nothing to
+ * parameterize (the SDK's tracked-lock table is the queue), so concurrent
+ * triggers (an ambiguous top-up failure racing the periodic
+ * `checkTopUps` sweep) collapse into whichever run is already pending.
+ * Network-constrained (resume talks to Core peers and Platform) with
+ * exponential backoff for the retry path.
+ */
+class ResumeTopUpsOperation(private val application: Application) {
+ companion object {
+ private val log = LoggerFactory.getLogger(ResumeTopUpsOperation::class.java)
+ const val WORK_NAME = "ResumeTopUpsWorker"
+ private const val BACKOFF_DELAY_SECONDS = 30L
+ }
+
+ fun enqueue() {
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_DELAY_SECONDS, TimeUnit.SECONDS)
+ .build()
+ WorkManager.getInstance(application)
+ .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP, request)
+ log.info("enqueued the top-up drain worker (KEEP — an existing run wins)")
+ }
+}
diff --git a/wallet/src/de/schildbach/wallet/service/platform/work/ResumeTopUpsWorker.kt b/wallet/src/de/schildbach/wallet/service/platform/work/ResumeTopUpsWorker.kt
new file mode 100644
index 0000000000..9ff8e09da5
--- /dev/null
+++ b/wallet/src/de/schildbach/wallet/service/platform/work/ResumeTopUpsWorker.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.platform.work
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import de.schildbach.wallet.service.platform.sdk.SdkTopUpRecoveryService
+import de.schildbach.wallet.service.work.BaseWorker
+import kotlinx.coroutines.CancellationException
+import org.dash.wallet.common.services.analytics.AnalyticsService
+import org.slf4j.LoggerFactory
+
+/**
+ * Phase B of the SDK top-up migration (#1520 item 3 / MO-998): the
+ * PAYLOAD-FREE drain worker that replaces the txid-based
+ * [TopupIdentityWorker] retry for SDK-created top-ups.
+ *
+ * The SDK's tracked-lock table IS the retry queue: this worker carries no
+ * input data at all — no txid, no identity, and (unlike
+ * [TopupIdentityWorker.KEY_PASSWORD]) no wallet password serialized into
+ * WorkManager's database; the SDK signs via the manager's live mnemonic
+ * resolver. One run = one [SdkTopUpRecoveryService.drainPendingTopUps] pass,
+ * idempotent by construction (consumed locks vanish from the recovery
+ * surface), so [androidx.work.ExistingWorkPolicy.KEEP] + a crash mid-run
+ * + a duplicate enqueue are all harmless.
+ *
+ * [Result.retry] (WorkManager's backoff) when the pass reports another
+ * attempt could make progress; success otherwise — including when locks
+ * remain but are terminal ([TopUpDrainReport.alreadyConsumed]), which
+ * retrying cannot fix.
+ */
+@HiltWorker
+class ResumeTopUpsWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted parameters: WorkerParameters,
+ private val sdkTopUpRecoveryService: SdkTopUpRecoveryService,
+ private val analytics: AnalyticsService
+) : BaseWorker(context, parameters) {
+ companion object {
+ private val log = LoggerFactory.getLogger(ResumeTopUpsWorker::class.java)
+ const val KEY_PENDING = "ResumeTopUpsWorker.PENDING"
+ const val KEY_RESUMED = "ResumeTopUpsWorker.RESUMED"
+ const val KEY_ALREADY_CONSUMED = "ResumeTopUpsWorker.ALREADY_CONSUMED"
+ const val KEY_FAILED = "ResumeTopUpsWorker.FAILED"
+ }
+
+ override suspend fun doWorkWithBaseProgress(): Result {
+ val report = try {
+ sdkTopUpRecoveryService.drainPendingTopUps()
+ } catch (t: Throwable) {
+ if (t is CancellationException) throw t
+ // drainPendingTopUps contains its own failures; this is
+ // belt-and-braces for anything unexpected.
+ analytics.logError(t, "Resume top-ups: drain pass failed")
+ return Result.retry()
+ }
+ log.info(
+ "drain pass: {} pending, {} resumed, {} already consumed, {} failed{}",
+ report.pending, report.resumed, report.alreadyConsumed, report.failed,
+ if (report.surfaceUnavailable) " (surface unavailable)" else ""
+ )
+ return if (report.retryNeeded) {
+ Result.retry()
+ } else {
+ Result.success(
+ workDataOf(
+ KEY_PENDING to report.pending,
+ KEY_RESUMED to report.resumed,
+ KEY_ALREADY_CONSUMED to report.alreadyConsumed,
+ KEY_FAILED to report.failed
+ )
+ )
+ }
+ }
+}
diff --git a/wallet/src/de/schildbach/wallet/service/platform/work/TopupIdentityOperation.kt b/wallet/src/de/schildbach/wallet/service/platform/work/TopupIdentityOperation.kt
deleted file mode 100644
index 4fcffda356..0000000000
--- a/wallet/src/de/schildbach/wallet/service/platform/work/TopupIdentityOperation.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * Copyright 2024 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.platform.work
-
-import android.annotation.SuppressLint
-import android.app.Application
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.liveData
-import androidx.lifecycle.switchMap
-import androidx.work.*
-import de.schildbach.wallet.security.SecurityGuard
-import de.schildbach.wallet.service.work.BaseWorker
-import de.schildbach.wallet.ui.dashpay.work.BroadcastUsernameVotesOperation
-import de.schildbach.wallet.ui.dashpay.work.BroadcastUsernameVotesWorker
-import org.bitcoinj.core.Sha256Hash
-import org.dash.wallet.common.data.Resource
-import org.dash.wallet.common.services.analytics.AnalyticsService
-import org.slf4j.LoggerFactory
-
-class TopupIdentityOperation(val application: Application) {
- class TopupIdentityOperationException(message: String) : java.lang.Exception(message)
-
- companion object {
- private val log = LoggerFactory.getLogger(TopupIdentityOperation::class.java)
-
- private const val WORK_NAME = "TopupIdentityWorker.WORK#"
- fun uniqueWorkName(workId: String) = "${WORK_NAME}$workId}"
-
- fun operationStatus(
- application: Application,
- workId: String,
- analytics: AnalyticsService
- ): LiveData> {
- val workManager: WorkManager = WorkManager.getInstance(application)
- return workManager.getWorkInfosForUniqueWorkLiveData(uniqueWorkName(workId)).switchMap {
- return@switchMap liveData {
- if (it.isNullOrEmpty()) {
- return@liveData
- }
-
- if (it.size > 1) {
- val e = RuntimeException("there should never be more than one unique work ${
- uniqueWorkName(
- workId
- )
- }")
- analytics.logError(e)
- throw e
- }
- emit(convertState(it.first()))
- }
- }
- }
-
- fun operationStatus(
- application: Application,
- txId: Sha256Hash,
- analytics: AnalyticsService
- ): LiveData> {
- val workManager: WorkManager = WorkManager.getInstance(application)
- return workManager.getWorkInfosByTagLiveData("txId:$txId").switchMap {
- return@switchMap liveData {
- if (it.isNullOrEmpty()) {
- return@liveData
- }
-
- if (it.size > 1) {
- val e = RuntimeException("there should never be more than one unique work $txId")
- analytics.logError(e)
- throw e
- }
- emit(convertState(it.first()))
- }
- }
- }
-
- fun allOperationsStatus(application: Application): LiveData>> {
- val workManager: WorkManager = WorkManager.getInstance(application)
- return workManager.getWorkInfosByTagLiveData(BroadcastUsernameVotesWorker::class.qualifiedName!!).switchMap {
- return@switchMap liveData {
- if (it.isNullOrEmpty()) {
- return@liveData
- }
-
- val result = mutableMapOf>()
- it.filter { workInfo ->
- val timestampTag = workInfo.tags.firstOrNull { it.startsWith("timestamp:") }
- timestampTag?.let {
- val timestamp = it.removePrefix("timestamp:").toLongOrNull()
- timestamp != null && timestamp > BroadcastUsernameVotesOperation.lastTimestamp
- } ?: false
- }.forEach { workInfo ->
- var toUserId = ""
- workInfo.tags.forEach { tag ->
- if (tag.startsWith("usernames:")) {
- toUserId = tag.replace("usernames:", "")
- }
- }
- result[toUserId] = convertState(workInfo)
- }
- emit(result)
- }
- }
- }
-
- private fun convertState(workInfo: WorkInfo): Resource {
- return when (workInfo.state) {
- WorkInfo.State.SUCCEEDED -> {
- Resource.success(workInfo)
- }
- WorkInfo.State.FAILED -> {
- val errorMessage = BaseWorker.extractError(workInfo.outputData)
- if (errorMessage != null) {
- Resource.error(errorMessage, workInfo)
- } else {
- Resource.error(Exception(), workInfo)
- }
- }
- WorkInfo.State.CANCELLED -> {
- Resource.canceled(workInfo)
- }
- else -> {
- Resource.loading(workInfo)
- }
- }
- }
- }
-
-// private val workManager: WorkManager = WorkManager.getInstance(application)
-//
-// /**
-// * Gets the list of all SendContactRequestWorker WorkInfo's
-// */
-// val allOperationsData = workManager.getWorkInfosByTagLiveData(TopupIdentityOperation::class.qualifiedName!!)
-
- @SuppressLint("EnqueueWork")
- fun create(identity: String, txId: Sha256Hash): WorkContinuation {
- val password = SecurityGuard.getInstance().retrievePassword()
- val topUpIdentityWorker = OneTimeWorkRequestBuilder()
- .setInputData(
- workDataOf(
- TopupIdentityWorker.KEY_PASSWORD to password,
- TopupIdentityWorker.KEY_IDENTITY to identity,
- TopupIdentityWorker.KEY_TOPUP_TX to txId.toString()
- )
- )
- .addTag("identity:$identity")
- .addTag("txId:$txId")
- .build()
-
- return WorkManager.getInstance(application)
- .beginUniqueWork(
- uniqueWorkName(identity),
- ExistingWorkPolicy.KEEP,
- topUpIdentityWorker
- )
- }
-}
\ No newline at end of file
diff --git a/wallet/src/de/schildbach/wallet/service/platform/work/TopupIdentityWorker.kt b/wallet/src/de/schildbach/wallet/service/platform/work/TopupIdentityWorker.kt
deleted file mode 100644
index 451781c63f..0000000000
--- a/wallet/src/de/schildbach/wallet/service/platform/work/TopupIdentityWorker.kt
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * Copyright 2024 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.platform.work
-
-import android.content.Context
-import androidx.hilt.work.HiltWorker
-import androidx.work.WorkerParameters
-import androidx.work.workDataOf
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedInject
-import de.schildbach.wallet.database.dao.TopUpsDao
-import de.schildbach.wallet.database.entity.TopUp
-import de.schildbach.wallet.service.platform.IdentityRepository
-import de.schildbach.wallet.service.platform.PlatformBroadcastService
-import de.schildbach.wallet.service.platform.TopUpRepository
-import de.schildbach.wallet.ui.dashpay.PlatformRepo
-import de.schildbach.wallet.service.work.BaseWorker
-import org.bitcoinj.core.InsufficientMoneyException
-import org.bitcoinj.core.Sha256Hash
-import org.bitcoinj.crypto.KeyCrypterException
-import org.bitcoinj.wallet.authentication.AuthenticationGroupExtension
-import org.bouncycastle.crypto.params.KeyParameter
-import de.schildbach.wallet.data.WalletData
-import org.dash.wallet.common.services.analytics.AnalyticsService
-import org.slf4j.LoggerFactory
-
-@HiltWorker
-class TopupIdentityWorker @AssistedInject constructor(
- @Assisted context: Context,
- @Assisted parameters: WorkerParameters,
- private val analytics: AnalyticsService,
- private val platformBroadcastService: PlatformBroadcastService,
- private val topUpRepository: TopUpRepository,
- private val walletDataProvider: WalletData,
- private val platformRepo: PlatformRepo,
- private val identityRepo: IdentityRepository,
- private val topUpsDao: TopUpsDao
-) : BaseWorker(context, parameters) {
- companion object {
- private val log = LoggerFactory.getLogger(TopupIdentityWorker::class.java)
- const val KEY_PASSWORD = "TopupIdentityWorker.PASSWORD"
- const val KEY_IDENTITY = "TopupIdentityWorker.IDENTITY"
- const val KEY_TOPUP_TX = "TopupIdentityWorker.TOPUP_TX"
- const val KEY_VALUE = "TopupIdentityWorker.VALUE"
- const val KEY_BALANCE = "TopupIdentityWorker.BALANCE"
- }
-
- override suspend fun doWorkWithBaseProgress(): Result {
- val password = inputData.getString(KEY_PASSWORD)
- ?: return Result.failure(workDataOf(KEY_ERROR_MESSAGE to "missing KEY_PASSWORD parameter"))
- val identity = inputData.getString(KEY_IDENTITY)
- ?: return Result.failure(workDataOf(KEY_ERROR_MESSAGE to "missing KEY_IDENTITY parameter"))
-
- val topupTxId = inputData.getString(KEY_TOPUP_TX)?.let { Sha256Hash.wrap(it) }
- val authGroupExtension = walletDataProvider.wallet!!.getKeyChainExtension(AuthenticationGroupExtension.EXTENSION_ID) as AuthenticationGroupExtension
- val topupTx = authGroupExtension.topupFundingTransactions.find { it.txId == topupTxId } ?: return Result.failure(workDataOf(KEY_ERROR_MESSAGE to "missing KEY_TOPUP_TX parameter"))
-
- val encryptionKey: KeyParameter
- try {
- encryptionKey = walletDataProvider.wallet!!.keyCrypter!!.deriveKey(password)
- } catch (ex: KeyCrypterException) {
- analytics.logError(ex, "Topup Identity: failed to derive encryption key")
- val msg = formatExceptionMessage("derive encryption key", ex)
- return Result.failure(workDataOf(KEY_ERROR_MESSAGE to msg))
- }
-
- return try {
- org.bitcoinj.core.Context.propagate(walletDataProvider.wallet!!.context)
- val existingTopup = topUpsDao.getByTxId(topupTx.txId)
- if (existingTopup != null && existingTopup.used()) {
- Result.success(
- workDataOf(
- KEY_IDENTITY to identity,
- KEY_TOPUP_TX to existingTopup.txId.toString(),
- KEY_BALANCE to identityRepo.getIdentityBalance()?.balance
- )
- )
- } else {
- val topupEntry = TopUp(toUserId = identity, workId = id.toString(), txId = topupTx.txId)
- topUpsDao.insert(topupEntry)
- topUpRepository.topUpIdentity(
- topupTx,
- encryptionKey
- )
- Result.success(
- workDataOf(
- KEY_IDENTITY to identity,
- KEY_TOPUP_TX to topupTx.txId.toString(),
- KEY_BALANCE to identityRepo.getIdentityBalance()?.balance
- )
- )
- }
- } catch (ex: Exception) {
- analytics.logError(ex, "Topup Identity: failed to topup identity")
- val args = when (ex) {
- is InsufficientMoneyException -> arrayOf(ex.missing.toString())
- else -> arrayOf()
- }
- Result.failure(
- workDataOf(
- KEY_IDENTITY to identity,
- KEY_TOPUP_TX to topupTx.txId.toString(),
- KEY_EXCEPTION to ex.javaClass.simpleName,
- KEY_ERROR_MESSAGE to formatExceptionMessage("topup exception:", ex),
- KEY_EXCEPTION_ARGS to args
- )
- )
- }
- }
-}
\ No newline at end of file
diff --git a/wallet/src/de/schildbach/wallet/ui/TransactionResultViewModel.kt b/wallet/src/de/schildbach/wallet/ui/TransactionResultViewModel.kt
index 7deeb94779..d0fabb04d6 100644
--- a/wallet/src/de/schildbach/wallet/ui/TransactionResultViewModel.kt
+++ b/wallet/src/de/schildbach/wallet/ui/TransactionResultViewModel.kt
@@ -26,11 +26,13 @@ import de.schildbach.wallet.WalletApplication
import de.schildbach.wallet.database.dao.TopUpsDao
import de.schildbach.wallet.database.entity.TopUp
import de.schildbach.wallet.service.platform.IdentityRepository
+import de.schildbach.wallet.service.platform.sdk.AssetLockKind
+import de.schildbach.wallet.service.platform.sdk.AssetLockKindResolver
import de.schildbach.wallet.service.platform.sdk.CutoverUiDataService
+import de.schildbach.wallet.service.platform.sdk.SdkTopUpRecoveryService
import de.schildbach.wallet.service.platform.sdk.SdkTxDetail
import de.schildbach.wallet.service.platform.sdk.SdkTxDetailProvider
import de.schildbach.wallet.service.platform.sdk.toDefaultMetadata
-import de.schildbach.wallet.service.platform.work.TopupIdentityOperation
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.bitcoinj.core.Sha256Hash
@@ -72,6 +74,8 @@ class TransactionResultViewModel @Inject constructor(
private val platformRepo: PlatformRepo,
private val sdkTxDetailProvider: SdkTxDetailProvider,
private val cutoverUiDataService: CutoverUiDataService,
+ private val assetLockKindResolver: AssetLockKindResolver,
+ private val sdkTopUpRecoveryService: SdkTopUpRecoveryService,
val analytics: AnalyticsService,
val walletApplication: WalletApplication
) : ViewModel() {
@@ -293,6 +297,22 @@ class TransactionResultViewModel @Inject constructor(
}
fun topUpStatus(txId: Sha256Hash): Flow = topUpsDao.observe(txId)
- fun topUpWork(txId: Sha256Hash): LiveData> =
- TopupIdentityOperation.operationStatus(walletApplication, txId, analytics)
+
+ /**
+ * Credited state for an SDK-era top-up (which has no `topups`-table
+ * row): true = credited, false = still pending (its lock awaits the
+ * credit transfer in the SDK's recovery queue), null = not an SDK
+ * top-up or state unknowable (SDK down). Read-only and no-boot.
+ *
+ * "True" here means "no pending lock found" — after a phrase restore
+ * the tracked-lock table is empty, so an unclaimed top-up ALSO reads
+ * as credited until chain rediscovery lands. Trust it for labels,
+ * nothing stronger.
+ */
+ suspend fun sdkTopUpCredited(txId: Sha256Hash): Boolean? {
+ val txHex = txId.toString()
+ if (assetLockKindResolver.kindFor(txHex) != AssetLockKind.TOPUP) return null
+ val pending = sdkTopUpRecoveryService.isTopUpPending(txHex) ?: return null
+ return !pending
+ }
}
diff --git a/wallet/src/de/schildbach/wallet/ui/more/ToolsFragment.kt b/wallet/src/de/schildbach/wallet/ui/more/ToolsFragment.kt
index 2ab2ac3ef0..8813c9aba7 100644
--- a/wallet/src/de/schildbach/wallet/ui/more/ToolsFragment.kt
+++ b/wallet/src/de/schildbach/wallet/ui/more/ToolsFragment.kt
@@ -110,8 +110,8 @@ class ToolsFragment : Fragment() {
}
} else {
SendCoinsActivity.startBuyCredits(requireActivity())
+ }
}
- }
private fun onTransactionExport() {
if (viewModel.uiState.value.isSyncing) {
diff --git a/wallet/src/de/schildbach/wallet/ui/more/ToolsViewModel.kt b/wallet/src/de/schildbach/wallet/ui/more/ToolsViewModel.kt
index 8c89c3face..4f0463da32 100644
--- a/wallet/src/de/schildbach/wallet/ui/more/ToolsViewModel.kt
+++ b/wallet/src/de/schildbach/wallet/ui/more/ToolsViewModel.kt
@@ -32,6 +32,7 @@ import de.schildbach.wallet.service.DashjDiagnosticSyncState
import de.schildbach.wallet.transactions.TaxBitExporter
import de.schildbach.wallet.ui.dashpay.utils.DashPayConfig
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -290,6 +291,25 @@ class ToolsViewModel @Inject constructor(
suspend fun setCreditsExplained() = dashPayConfig.set(DashPayConfig.CREDIT_INFO_SHOWN, true)
+ /**
+ * Persist "the credits explainer has been seen" from a scope that
+ * OUTLIVES the dialog. Writing it inside the sheet's own lifecycle
+ * scope loses the race when the user dismisses immediately — the
+ * coroutine is cancelled before the DataStore write lands and the
+ * explainer re-appears on the next visit (observed on device).
+ * [NonCancellable] also protects it from this ViewModel being cleared
+ * as the Buy Credits screen launches.
+ */
+ fun markCreditsExplained() {
+ viewModelScope.launch(NonCancellable) {
+ try {
+ setCreditsExplained()
+ } catch (e: Exception) {
+ log.warn("failed to persist the credits-explainer flag", e)
+ }
+ }
+ }
+
suspend fun creditsExplained() = dashPayConfig.get(DashPayConfig.CREDIT_INFO_SHOWN) ?: false
suspend fun hasUsername(): Boolean {
diff --git a/wallet/src/de/schildbach/wallet/ui/more/tools/WhatAreCreditsDialogFragment.kt b/wallet/src/de/schildbach/wallet/ui/more/tools/WhatAreCreditsDialogFragment.kt
index 75560cb0bf..151be695c4 100644
--- a/wallet/src/de/schildbach/wallet/ui/more/tools/WhatAreCreditsDialogFragment.kt
+++ b/wallet/src/de/schildbach/wallet/ui/more/tools/WhatAreCreditsDialogFragment.kt
@@ -41,14 +41,18 @@ class WhatAreCreditsDialogFragment : OffsetDialogFragment(R.layout.dialog_what_a
dismiss()
}
binding.homeIndicator.isVisible = !showCloseButton
+ // Mark the explainer as seen as soon as it is DISPLAYED, from a scope
+ // that OUTLIVES this sheet. Writing on dismissal missed every
+ // non-button close (swipe-down, tap outside, back); writing in this
+ // fragment's own scope then lost the race when the sheet was
+ // dismissed immediately (the write was cancelled mid-flight, so the
+ // explainer still re-appeared).
+ viewModel.markCreditsExplained()
}
override fun dismiss() {
- lifecycleScope.launch {
- viewModel.setCreditsExplained()
- onDismissAction?.invoke()
- super.dismiss()
- }
+ onDismissAction?.invoke()
+ super.dismiss()
}
fun show(fragmentActivity: FragmentActivity, onDismissAction: () -> Unit) {
diff --git a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt
index 1d9265170e..cd7cfa7e6c 100644
--- a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt
+++ b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt
@@ -1,7 +1,5 @@
package de.schildbach.wallet.ui.send
-import android.app.Activity
-import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
@@ -10,46 +8,51 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.drop
import de.schildbach.wallet.data.CreditBalanceInfo
-import de.schildbach.wallet.integration.android.BitcoinIntegration
-import de.schildbach.wallet.service.platform.sdk.SdkWriteResult
+import androidx.work.WorkInfo
+import de.schildbach.wallet.service.platform.work.PerformTopUpOperation
+import de.schildbach.wallet.service.platform.work.PerformTopUpWorker
+import de.schildbach.wallet.service.work.BaseWorker
import de.schildbach.wallet.ui.more.tools.ConfirmTopUpDialogFragment
import de.schildbach.wallet_test.R
import kotlinx.coroutines.launch
-import org.bitcoinj.core.Coin
-import org.bitcoinj.core.InsufficientMoneyException
-import org.bitcoinj.core.Transaction
-import org.bitcoinj.crypto.KeyCrypterException
-import org.bitcoinj.utils.ExchangeRate
-import org.dash.wallet.common.money.MonetaryFormat
-import org.bitcoinj.wallet.Wallet
-import org.dash.wallet.common.services.LeftoverBalanceException
-import org.dash.wallet.common.services.analytics.AnalyticsConstants
+import org.dash.wallet.common.money.Coin
import org.dash.wallet.common.ui.dialogs.AdaptiveDialog
-import org.dash.wallet.common.ui.dialogs.MinimumBalanceDialog
import org.slf4j.LoggerFactory
-import de.schildbach.wallet.util.format
-import de.schildbach.wallet.util.setAmount
-import de.schildbach.wallet.util.setFiatAmount
-import de.schildbach.wallet.util.toDashjFiat
-import de.schildbach.wallet.util.toDashjCoin
import de.schildbach.wallet.util.toNeutralCoin
-import de.schildbach.wallet.util.toNeutralFiat
-import de.schildbach.wallet.util.toTxId
-import de.schildbach.wallet.util.toSha256Hash
class BuyCreditsFragment : SendCoinsFragment() {
companion object {
private val log = LoggerFactory.getLogger(BuyCreditsFragment::class.java)
+
+ /**
+ * The smallest top-up this screen accepts (INCLUSIVE bound) — the
+ * same figure the worker refuses to adjust below, which is the FFI's
+ * inclusive MIN_TOP_UP_DUFFS floor. Anything the screen lets through
+ * must be an amount Platform accepts.
+ */
+ private val MIN_TOP_UP = Coin.valueOf(PerformTopUpWorker.PLATFORM_TOP_UP_FLOOR_DUFFS)
}
private val buyCreditsViewModel by viewModels()
+ /**
+ * Set once THIS view has seen the purchase actually running. WorkManager
+ * replays the last finished unique work to a new observer, so a terminal
+ * state delivered before any active state is a leftover from a previous
+ * visit — acting on it would re-fire the failure dialog (or finish the
+ * screen) on entry. This gate is what the old global pruneWork() bought,
+ * without erasing every other feature's finished work records.
+ */
+ private var sawActiveTopUp = false
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.paymentHeader.setTitle(getString(R.string.credit_balance_button_buy))
- enterAmountViewModel.setMinAmount(org.dash.wallet.common.money.Coin.valueOf(50_000))
+ enterAmountViewModel.setMinAmount(MIN_TOP_UP, isIncludedMin = true)
binding.paymentHeader.setPreposition("")
- viewModel.isAssetLock = true
+ // (The base's `viewModel.isAssetLock = true` dashj tripwire is gone
+ // with the dashj purchase path: handleGo is overridden to the SDK
+ // worker and can never reach signAndSendPayment.)
// Show what the identity already holds, under the amount field.
//
@@ -71,26 +74,37 @@ class BuyCreditsFragment : SendCoinsFragment() {
}
}
}
+ // Observe from view creation, ONCE per view: re-observing on every
+ // purchase stacked observers (two failure dialogs on the second
+ // failed purchase in one session), and observing only after Continue
+ // hid the spinner from a user re-entering mid-purchase.
+ observeTopUpWork()
}
override fun updateView() {
val isReplaying = viewModel.isBlockchainReplaying.value
- val dryRunException = viewModel.dryRunException
- if (isReplaying != true && dryRunException != null) {
- when (dryRunException) {
- is InsufficientMoneyException -> {
- val errorMessage = getErrorMessage(R.string.credit_balance_insufficient_error_message)
- enterAmountFragment?.setError(errorMessage)
- return
- }
- else -> {}
- }
+ if (isReplaying != true && viewModel.isInsufficientFunds) {
+ val errorMessage = getErrorMessage(R.string.credit_balance_insufficient_error_message)
+ enterAmountFragment?.setError(errorMessage)
+ return
}
+ // Below the top-up minimum the Continue button greys out; say WHY
+ // instead of leaving the user guessing (the button enables AT the
+ // minimum — inclusive, matching the FFI floor).
+ val entered = enterAmountViewModel.amount.value
+ if (entered != null && entered.isPositive && entered.isLessThan(MIN_TOP_UP)) {
+ enterAmountFragment?.setError(
+ getString(R.string.buy_credits_below_minimum, MIN_TOP_UP.toFriendlyString())
+ )
+ return
+ }
+ enterAmountFragment?.setError("")
+
// if there is no value (null) or it is zero, then display the message in the
// enter amount fragment using 0.01 DASH
- val amount = enterAmountViewModel.amount.value?.toDashjCoin() ?: Coin.CENT
+ val amount = entered ?: Coin.CENT
val operations = if (amount.isZero) {
Coin.CENT.value
} else {
@@ -121,152 +135,67 @@ class BuyCreditsFragment : SendCoinsFragment() {
}
override suspend fun showPaymentConfirmation() {
- val dryRunRequest = viewModel.dryrunSendRequest ?: return
- //val address = viewModel.basePaymentIntent.address?.toBase58() ?: return
-
- val txFee = dryRunRequest.tx.fee
- val amount: Coin?
- val total: String?
-
- if (dryRunRequest.emptyWallet) {
- amount = enterAmountViewModel.amount.value?.toDashjCoin()?.minus(txFee)
- total = enterAmountViewModel.amount.value?.toPlainString()
- } else {
- amount = enterAmountViewModel.amount.value?.toDashjCoin()
- total = amount?.add(txFee ?: Coin.ZERO)?.toPlainString()
- }
-
- val rate = enterAmountViewModel.selectedExchangeRate.value
- val exchangeRate = rate?.let {
- org.dash.wallet.common.money.ExchangeRate(org.dash.wallet.common.money.Coin.COIN, rate.fiat)
- }
- val amountStr = amount?.let { MonetaryFormat.BTC.noCode().format(it).toString() } ?: ""
- val fee = txFee?.toPlainString() ?: ""
-
- //var dashPayProfile: DashPayProfile? = null
-
-// if (viewModel.contactData.value?.requestReceived == true) {
-// dashPayProfile = viewModel.contactData.value?.dashPayProfile
-// }
-//
-// val isPendingContactRequest = viewModel.contactData.value?.isPendingRequest == true
-// val username = dashPayProfile?.username
-// val displayName = (dashPayProfile?.displayName ?: "").ifEmpty { username }
-// val avatarUrl = dashPayProfile?.avatarUrl
-
- // need to put the conformation for used with Create UserName
+ // The dialog reads the amount and rate it displays from the shared
+ // SendCoinsViewModel and its own ViewModel, so nothing is computed or
+ // passed here. (The dashj dry-run figures this method used to derive —
+ // fee, total, send-max amount — were never read by anything, and the
+ // null `tx.fee` post-cutover made deriving them a crash risk.)
val dialog = ConfirmTopUpDialogFragment()
dialog.show(requireActivity()) { confirmed ->
if (confirmed) {
lifecycleScope.launch {
- handleGo(true)
+ handleGo()
}
}
}
}
- private suspend fun handleGo(checkBalance: Boolean) {
- if (viewModel.dryrunSendRequest == null) {
- log.error("illegal state dryrunSendRequest == null")
- return
- }
-
- val editedAmount = enterAmountViewModel.amount.value
- val rate = enterAmountViewModel.selectedExchangeRate.value
-
- if (editedAmount != null) {
- // Post-cutover the dashj L1 engine is HELD (0 UTXOs), so building
- // the top-up asset lock with dashj fails InsufficientMoneyException
- // — the funds live in the SDK. Route top-up funding through the
- // SDK's fused topUpFromCore (resume-gated) instead of the dashj
- // asset-lock + TopupIdentityWorker chain. There is NO dashj tx/txid,
- // so the SDK outcome is observed directly (no TransactionResult
- // screen). Pre-cutover this branch is skipped and the dashj path
- // below is byte-for-byte unchanged.
- if (buyCreditsViewModel.isCutoverCommitted()) {
- handleSdkTopUp(editedAmount.toDashjCoin().value)
- viewModel.resetState()
- return
- }
-
- val exchangeRate = rate?.fiat?.let { ExchangeRate(Coin.COIN, it.toDashjFiat()) }
-
- try {
- // TODO: there are no events for Topups
- // viewModel.logEvent(AnalyticsConstants.Topup.ENTER_AMOUNT_TOPUP)
-
- val maxSelected = enterAmountFragment?.maxSelected ?: false
- if (maxSelected) {
- viewModel.logEvent(AnalyticsConstants.SendReceive.ENTER_AMOUNT_MAX)
- }
- // buy do an asset lock transaction or we do this in the worker?
- val topUpKey = viewModel.getNextKey()
- val tx = viewModel.signAndSendAssetLock(editedAmount.toDashjCoin(), exchangeRate, checkBalance, topUpKey, maxSelected)
- buyCreditsViewModel.topUpTransaction = tx
-
- onSignAndSendPaymentSuccess(tx)
- } catch (ex: LeftoverBalanceException) {
- val shouldContinue = MinimumBalanceDialog().showAsync(requireActivity())
-
- if (shouldContinue == true) {
- handleGo(false)
- }
- } catch (ex: InsufficientMoneyException) {
- showInsufficientMoneyDialog(ex.missing ?: Coin.ZERO)
- } catch (ex: KeyCrypterException) {
- log.info("send topup failure (encryption)", ex)
- showFailureDialog(ex)
- } catch (ex: Wallet.CouldNotAdjustDownwards) {
- showEmptyWalletFailedDialog()
- } catch (ex: Exception) {
- showFailureDialog(ex)
- }
-
- viewModel.resetState()
- }
+ /**
+ * Phase 2/3 (MO-998): SDK-only — the dashj purchase path
+ * (signAndSendAssetLock + TopupIdentityWorker) is deleted. Pre-cutover,
+ * [de.schildbach.wallet.service.platform.sdk.SdkTransparentTopUp]'s
+ * fail-closed gate refuses with NotBroadcast and nothing is spent.
+ */
+ /**
+ * Whether [amount] is a MAX ("spend everything") purchase — the whole
+ * spendable balance, keyed off the SDK-overlaid balance (dashj's is held
+ * at 0 post-cutover). Same rule the shielded Internal Transfer screen
+ * uses (`amount == availableBalance` in ShieldedTransferViewModel).
+ */
+ private fun isMaxSpend(amount: Coin): Boolean {
+ val available = viewModel.maxOutputAmount.value?.toNeutralCoin() ?: return false
+ return available.isPositive && amount.isGreaterThanOrEqualTo(available)
}
- private fun onSignAndSendPaymentSuccess(transaction: Transaction) {
-// viewModel.logSentEvent(enterAmountViewModel.dashToFiatDirection.value ?: true)
- val callingActivity = requireActivity().callingActivity
-
- if (callingActivity != null) {
- log.info("returning result to calling activity: {}", callingActivity.flattenToString())
- val resultIntent = Intent()
- BitcoinIntegration.transactionHashToResult(
- resultIntent,
- transaction.txId.toString()
- )
- requireActivity().setResult(Activity.RESULT_OK, resultIntent)
- }
- lifecycleScope.launch {
- buyCreditsViewModel.topUpOnPlatform()
- showTransactionResult(transaction, false)
- playSentSound()
- requireActivity().finish()
- }
+ private suspend fun handleGo() {
+ val editedAmount = enterAmountViewModel.amount.value ?: return
+ // MAX follows the shielded Internal Transfer pattern: submit the FULL
+ // balance and let the worker make ONE fee-adjusted retry when the
+ // asset-lock coin selection comes up short pre-broadcast (the exact
+ // L1 fee is unknowable app-side). See PerformTopUpWorker.
+ val maxSpend = enterAmountFragment?.maxSelected == true || isMaxSpend(editedAmount)
+ handleSdkTopUp(editedAmount.value, maxSpend)
+ viewModel.resetState()
}
/**
- * Post-cutover top-up: fund the identity's credit balance through the SDK's
- * resume-gated, fused topUpFromCore and observe the three-valued outcome
- * directly. Unlike the dashj path there is no funding Transaction, so the
- * TransactionResultActivity screen is skipped — the new credit balance is
- * surfaced by the credits UI on return.
- *
- * Funds safety: the executor runs the mandatory resume gate before any
- * fresh build and never falls back to dashj. NotBroadcast means nothing was
- * spent (retry-safe); Ambiguous means the top-up MAY be on chain — the
- * executor keeps it sticky (refuses any further attempt this process) and
+ * The purchase runs as UNIQUE background work ([PerformTopUpWorker] via
+ * the ViewModel) so a lock screen / rotation / process death cannot
+ * cancel it mid-flight; this screen only OBSERVES the work. Success →
+ * finish (the credits UI shows the new balance on return); failure with
+ * nothing spent → standard error dialog, retry-safe; unconfirmed →
+ * the recovery worker completes any tracked lock in the background and
* the user is told NOT to retry.
*/
- private suspend fun handleSdkTopUp(amountDuffs: Long) {
+ private suspend fun handleSdkTopUp(amountDuffs: Long, isMaxSpend: Boolean) {
// PRE-FLIGHT funding eligibility: the asset-lock build only selects
// FINAL (confirmed/IS-locked) BIP44 coins — refuse HERE, before the
// spend attempt, when a display balance backed by non-final or
// out-of-account outputs cannot fund the lock (fail-open on any
- // preflight hiccup; the real build stays authoritative).
- if (!buyCreditsViewModel.canFundTopUp(amountDuffs)) {
+ // preflight hiccup; the real build stays authoritative). A MAX spend
+ // is preflighted on its fee-adjusted retry amount — the full balance
+ // can never clear the preflight's fee headroom by definition.
+ if (!buyCreditsViewModel.canFundTopUp(amountDuffs, isMaxSpend)) {
AdaptiveDialog.create(
R.drawable.ic_error,
getString(R.string.credit_balance_button_buy),
@@ -279,30 +208,49 @@ class BuyCreditsFragment : SendCoinsFragment() {
).showAsync(requireActivity())
return
}
- val progress = AdaptiveDialog.progress(getString(R.string.send_coins_sending_msg))
- progress.show(parentFragmentManager, "buy_credits_sdk_topup")
- val result = try {
- buyCreditsViewModel.topUpViaSdk(amountDuffs)
- } finally {
- progress.dismissAllowingStateLoss()
- }
+ buyCreditsViewModel.startTopUp(amountDuffs, isMaxSpend)
+ }
- when (result) {
- is SdkWriteResult.Broadcast -> {
- log.info("SDK top-up broadcast; new credit balance {}", result.value)
- onSdkTopUpSuccess()
- }
- is SdkWriteResult.NotBroadcast -> {
- // Provably nothing spent — retry-safe. Surface the standard
- // send-error dialog so the user can try again.
- log.warn("SDK top-up not sent: {}", result.reason)
- showFailureDialog(Exception(result.reason))
- }
- is SdkWriteResult.Ambiguous -> {
- // The top-up MAY have gone through; the executor is sticky and
- // refuses any retry. Never offer a retry (double-pay risk).
- log.error("SDK top-up outcome unconfirmed", result.cause)
- showSdkTopUpAmbiguousDialog()
+ private fun observeTopUpWork() {
+ buyCreditsViewModel.topUpWorkStatus().observe(viewLifecycleOwner) { infos ->
+ val work = infos.lastOrNull() ?: return@observe
+ when (work.state) {
+ WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> {
+ sawActiveTopUp = true
+ // Progress circle on the Send button for as long as the
+ // purchase is actually running — the screen must stay
+ // busy until the work reaches a terminal state, because
+ // a FAILURE must be shown HERE (closing at the SDK
+ // hand-off was tried and reverted: it silenced every
+ // post-hand-off failure dialog, e.g. a MAX retry refused
+ // below the Platform floor). A MAX purchase waiting for
+ // a chain-locked block legitimately holds this spinner
+ // for minutes; the purchase itself survives the screen
+ // (unique work + recovery worker) if the user backs out.
+ enterAmountFragment?.setContinueLoading(true)
+ }
+ WorkInfo.State.SUCCEEDED -> {
+ if (!sawActiveTopUp) return@observe
+ // Deliberately do NOT clear the loading state: the screen
+ // is about to finish, and re-enabling the button first
+ // leaves a brief window where it looks tappable again.
+ log.info(
+ "SDK top-up credited; new balance {}",
+ work.outputData.getLong(PerformTopUpWorker.KEY_NEW_BALANCE, -1)
+ )
+ onSdkTopUpSuccess()
+ }
+ WorkInfo.State.FAILED -> {
+ if (!sawActiveTopUp) return@observe
+ enterAmountFragment?.setContinueLoading(false)
+ val ambiguous = work.outputData.getBoolean(PerformTopUpWorker.KEY_AMBIGUOUS, false)
+ val reason = work.outputData.getString(BaseWorker.KEY_ERROR_MESSAGE) ?: "top-up failed"
+ log.warn("SDK top-up failed (ambiguous={}): {}", ambiguous, reason)
+ lifecycleScope.launch {
+ if (ambiguous) showSdkTopUpAmbiguousDialog() else showFailureDialog(Exception(reason))
+ }
+ }
+ WorkInfo.State.CANCELLED -> enterAmountFragment?.setContinueLoading(false)
}
}
}
diff --git a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt
index b5cf7ca87f..1356e157ff 100644
--- a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt
+++ b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt
@@ -2,61 +2,48 @@ package de.schildbach.wallet.ui.send
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import de.schildbach.wallet.WalletApplication
+import de.schildbach.wallet.data.CreditBalanceInfo
import de.schildbach.wallet.database.entity.BlockchainIdentityConfig
+import de.schildbach.wallet.service.platform.sdk.ASSET_LOCK_PREFLIGHT_FEE_HEADROOM_DUFFS
import de.schildbach.wallet.service.platform.sdk.SdkAssetLockFundingPreflight
-import de.schildbach.wallet.service.platform.sdk.SdkTransparentTopUp
-import de.schildbach.wallet.service.platform.sdk.SdkWriteResult
-import de.schildbach.wallet.service.platform.work.TopupIdentityOperation
+import de.schildbach.wallet.service.platform.work.PerformTopUpOperation
import de.schildbach.wallet.ui.dashpay.PlatformRepo
-import de.schildbach.wallet.ui.dashpay.utils.DashPayConfig
+import de.schildbach.wallet.ui.shielded.assetLockMaxFeeReserve
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.withContext
-import org.bitcoinj.core.Sha256Hash
-import org.bitcoinj.core.Transaction
-import de.schildbach.wallet.data.CreditBalanceInfo
-import de.schildbach.wallet.data.WalletData
-import org.dash.wallet.common.data.Resource
-import org.dash.wallet.common.services.analytics.AnalyticsService
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
-import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.withContext
import org.bitcoinj.core.Coin
import org.dashj.platform.dpp.identifier.Identifier
import org.slf4j.LoggerFactory
import javax.inject.Inject
+/**
+ * Buy Credits is SDK-only (Phase 2/3, MO-998): the dashj purchase path and
+ * its TopupIdentityWorker/topup-counter plumbing are deleted. The purchase
+ * runs as UNIQUE background work ([startTopUp] →
+ * [de.schildbach.wallet.service.platform.work.PerformTopUpWorker]) so a
+ * lock screen / rotation / process death cannot cancel it mid-flight;
+ * interrupted attempts are completed by
+ * [de.schildbach.wallet.service.platform.work.ResumeTopUpsWorker].
+ */
@HiltViewModel
class BuyCreditsViewModel @Inject constructor(
- val walletApplication: WalletApplication,
- val platformRepo: PlatformRepo,
- val identity: BlockchainIdentityConfig,
- val walletDataProvider: WalletData,
- val analytics: AnalyticsService,
- val dashPayConfig: DashPayConfig,
- private val sdkTransparentTopUp: SdkTransparentTopUp,
+ private val walletApplication: WalletApplication,
+ private val platformRepo: PlatformRepo,
+ private val identity: BlockchainIdentityConfig,
private val assetLockFundingPreflight: SdkAssetLockFundingPreflight
) : ViewModel() {
companion object {
private val log = LoggerFactory.getLogger(BuyCreditsViewModel::class.java)
}
- var identityId: String? = null
- var topUpTransaction: Transaction? = null
- private val _currentWorkId = MutableStateFlow("")
- val currentWorkId: StateFlow
- get() = _currentWorkId
-
- private suspend fun getNextWorkId() = withContext(Dispatchers.IO) {
- dashPayConfig.getTopupCounter().toString(16)
- }
-
- private val topupIdentityOperation = TopupIdentityOperation(walletApplication)
-
/**
* The identity's CURRENT credit balance, expressed in Dash for display.
*
@@ -93,49 +80,26 @@ class BuyCreditsViewModel @Inject constructor(
}
}
- fun topWorkStatus(workId: String): LiveData> {
- return TopupIdentityOperation.operationStatus(walletApplication, workId, analytics)
- }
-
- suspend fun topUpOnPlatform() = withContext(Dispatchers.IO) {
- identity.get(BlockchainIdentityConfig.IDENTITY_ID)?.let { identityId ->
- val workId = getNextWorkId()
- topupIdentityOperation
- .create(workId, topUpTransaction?.txId!!)
- .enqueue()
- _currentWorkId.value = workId
- }
- }
-
- suspend fun getTransaction(txId: Sha256Hash?) = withContext(Dispatchers.IO) {
- walletDataProvider.wallet!!.getTransaction(txId)
- }
-
/**
- * Whether the cutover is committed. Post-cutover the dashj L1 engine is
- * HELD (0 UTXOs), so building the top-up asset lock with dashj fails —
- * the funds live in the SDK, and the go handler routes funding through
- * [topUpViaSdk] instead of the dashj asset-lock + [TopupIdentityWorker]
- * chain. Pre-cutover this is false and the existing dashj path is used
- * byte-for-byte.
+ * Start the purchase as unique background work. A tap while one runs
+ * attaches to the existing run (no double buy). The screen drives its
+ * UI from [topUpWorkStatus]. [isMaxSpend] marks a "spend everything"
+ * purchase: the worker may make ONE fee-adjusted retry when the full
+ * balance fails the asset-lock coin selection pre-broadcast.
*/
- suspend fun isCutoverCommitted(): Boolean = sdkTransparentTopUp.isCutoverCommitted()
+ fun startTopUp(amountDuffs: Long, isMaxSpend: Boolean) {
+ PerformTopUpOperation(walletApplication).enqueue(amountDuffs, isMaxSpend)
+ }
/**
- * Post-cutover top-up: fund the EXISTING identity's credit balance by
- * [amountDuffs] Core duffs (the user-entered amount) directly through the
- * SDK's resume-gated `topUpFromCore` (which FUSES the asset-lock build with
- * the Platform top-up registration — no dashj tx/txid). Returns the
- * three-valued outcome the go handler observes directly: Broadcast(new
- * credit balance) / NotBroadcast (nothing spent, retry-safe) / Ambiguous
- * (unconfirmed — never retried). Returns NotBroadcast when no identity id
- * is on record.
+ * Live status of the unique purchase work (empty until first use).
+ * Delivers the last FINISHED run to a fresh observer too — the screen
+ * gates on having seen an active state, rather than pruning (a
+ * WorkManager prune is app-global and would erase finished work states
+ * other features still observe by tag).
*/
- suspend fun topUpViaSdk(amountDuffs: Long): SdkWriteResult = withContext(Dispatchers.IO) {
- val identityId = identity.get(BlockchainIdentityConfig.IDENTITY_ID)
- ?: return@withContext SdkWriteResult.NotBroadcast("no identity to top up")
- sdkTransparentTopUp.topUp(identityId, amountDuffs)
- }
+ fun topUpWorkStatus(): LiveData> =
+ PerformTopUpOperation.status(walletApplication)
/**
* PRE-FLIGHT funding-eligibility for an SDK top-up of [amountDuffs]:
@@ -146,7 +110,30 @@ class BuyCreditsViewModel @Inject constructor(
* the preflight has no evidence (pre-cutover, SDK unavailable, read
* failure) — the real build stays the authority.
*/
- suspend fun canFundTopUp(amountDuffs: Long): Boolean = withContext(Dispatchers.IO) {
- assetLockFundingPreflight.canFundAssetLockDuffs(amountDuffs) ?: true
- }
-}
\ No newline at end of file
+ suspend fun canFundTopUp(amountDuffs: Long, isMaxSpend: Boolean): Boolean =
+ withContext(Dispatchers.IO) {
+ // A MAX spend can never clear the preflight at the FULL balance —
+ // the check demands fee headroom ON TOP of the amount, and there is
+ // nothing on top of everything. Preflight what the worker's
+ // fee-adjusted retry would actually send instead: the same reserve
+ // rule the shielded Internal Transfer max uses
+ // (assetLockMaxFeeReserve, sized from the spendable UTXO count).
+ val effective = if (isMaxSpend) {
+ // Same source the worker's retry uses: the SDK's own
+ // eligible-UTXO count. The preflight re-adds its fixed fee
+ // headroom to whatever it is asked about, and for a MAX spend
+ // the withheld reserve IS the fee allowance — so subtract the
+ // headroom here too, or the two stack and a MAX preflight
+ // (eligible == amount) can never pass. Net effect: the check
+ // becomes eligible + reserve >= amount, i.e. "is no more than
+ // the fee reserve tied up in non-final coins".
+ val count = assetLockFundingPreflight.eligibleAssetLockUtxoCountOrNull()
+ val reserve = count?.let { assetLockMaxFeeReserve(it).duffs } ?: 0L
+ (amountDuffs - reserve - ASSET_LOCK_PREFLIGHT_FEE_HEADROOM_DUFFS)
+ .coerceAtLeast(1L)
+ } else {
+ amountDuffs
+ }
+ assetLockFundingPreflight.canFundAssetLockDuffs(effective) ?: true
+ }
+}
diff --git a/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt b/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt
index 52e6d1bbd8..1831c60ca3 100644
--- a/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt
+++ b/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt
@@ -90,7 +90,6 @@ class SendCoinsViewModel @Inject constructor(
) : SendCoinsBaseViewModel(walletDataProvider, configuration) {
companion object {
private val log = LoggerFactory.getLogger(SendCoinsViewModel::class.java)
- private val dryRunKey = ECKey()
}
enum class State {
@@ -117,6 +116,14 @@ class SendCoinsViewModel @Inject constructor(
var dryRunException: Exception? = null
private set
+ /**
+ * Whether the last dry run failed for lack of funds, as a plain boolean so
+ * screens do not have to know the dashj exception type (Phase 3 keeps the
+ * dashj surface inside the ViewModel).
+ */
+ val isInsufficientFunds: Boolean
+ get() = dryRunException is InsufficientMoneyException
+
/**
* Phase 5d: a DISPLAY-only, deterministic fee estimate for the confirm
* dialog when the post-cutover dry-run does NOT complete the tx (so
@@ -149,8 +156,6 @@ class SendCoinsViewModel @Inject constructor(
val contactData: LiveData
get() = _contactData
- /** the resulting transaction is an asset lock transaction (default = false) */
- var isAssetLock = false
init {
blockchainStateDao.observeState()
@@ -254,9 +259,6 @@ class SendCoinsViewModel @Inject constructor(
): Transaction = withContext(Dispatchers.IO) {
Context.propagate(wallet.context)
_state.postValue(State.SENDING)
- if (isAssetLock) {
- error("isAssetLock must be false, but is true")
- }
val finalPaymentIntent = basePaymentIntent.mergeWithEditedValues(editedAmount.toNeutralCoin(), null)
val transaction = try {
@@ -315,59 +317,6 @@ class SendCoinsViewModel @Inject constructor(
transaction
}
- suspend fun signAndSendAssetLock(
- editedAmount: Coin,
- exchangeRate: ExchangeRate?,
- checkBalance: Boolean,
- key: ECKey,
- emptyWallet: Boolean
- ): Transaction = withContext(Dispatchers.IO) {
- _state.postValue(State.SENDING)
- if (!isAssetLock) {
- error("isAssetLock must be true, but is false")
- }
- val finalPaymentIntent = basePaymentIntent.mergeWithEditedValues(editedAmount.toNeutralCoin(), null)
-
- val transaction = try {
- var finalSendRequest = sendCoinsTaskRunner.createAssetLockSendRequest(
- basePaymentIntent.mayEditAmount(),
- finalPaymentIntent,
- true,
- dryrunSendRequest!!.ensureMinRequiredFee,
- key
- )
- finalSendRequest.memo = basePaymentIntent.memo
- finalSendRequest.exchangeRate = exchangeRate
- Context.propagate(wallet.context)
-
- if (emptyWallet) {
- sendCoinsTaskRunner.signSendRequest(finalSendRequest)
- wallet.completeTx(finalSendRequest)
-
- // make sure that the asset lock payload matches the OP_RETURN output
- val outputValue = finalSendRequest.tx.outputs.first().value
- val assetLockedValue = (finalSendRequest.tx as AssetLockTransaction).assetLockPayload.creditOutputs.first().value
- if (assetLockedValue != outputValue) {
- val newRequest = SendRequest.assetLock(wallet.params, key, outputValue, true)
- newRequest.coinSelector = finalSendRequest.coinSelector
- newRequest.returnChange = finalSendRequest.returnChange
- newRequest.aesKey = finalSendRequest.aesKey
- finalSendRequest = newRequest
- } else {
- // this shouldn't happen
- error("The asset lock value is the same as the output though emptying the wallet")
- }
- }
-
- sendCoinsTaskRunner.sendCoins(finalSendRequest, checkBalanceConditions = checkBalance)
- } catch (ex: Exception) {
- _state.postValue(State.FAILED)
- throw ex
- }
-
- _state.postValue(State.SENT)
- transaction
- }
fun allowBiometric(): Boolean {
val thresholdAmount = Coin.parseCoin(configuration.biometricLimit.toString())
@@ -454,7 +403,7 @@ class SendCoinsViewModel @Inject constructor(
return isInitialized && basePaymentIntent.hasOutputs()
}
- /** creates a send request using the payment intent and [isAssetLock] */
+ /** creates a send request using the payment intent */
private fun createSendRequest(
mayEditAmount: Boolean,
paymentIntent: PaymentIntent,
@@ -462,22 +411,12 @@ class SendCoinsViewModel @Inject constructor(
forceEnsureMinRequiredFee: Boolean
//useGreedyAlgorithm: Boolean = true
): SendRequest {
- return if (!isAssetLock) {
- sendCoinsTaskRunner.createSendRequest(
- mayEditAmount,
- paymentIntent,
- signInputs,
- forceEnsureMinRequiredFee
- )
- } else {
- sendCoinsTaskRunner.createAssetLockSendRequest(
- mayEditAmount,
- paymentIntent,
- signInputs,
- forceEnsureMinRequiredFee,
- dryRunKey
- )
- }
+ return sendCoinsTaskRunner.createSendRequest(
+ mayEditAmount,
+ paymentIntent,
+ signInputs,
+ forceEnsureMinRequiredFee
+ )
}
fun setAmount(amount: Coin) {
@@ -703,12 +642,4 @@ class SendCoinsViewModel @Inject constructor(
}
}
- fun getNextKey(): ECKey {
- val authGroup = wallet.getKeyChainExtension(
- AuthenticationGroupExtension.EXTENSION_ID
- ) as AuthenticationGroupExtension
- return authGroup.freshKey(
- AuthenticationKeyChain.KeyChainType.BLOCKCHAIN_IDENTITY_TOPUP
- ) as ECKey
- }
}
diff --git a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionDetailsDialogFragment.kt b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionDetailsDialogFragment.kt
index 9d034cab9e..14811c4c1c 100644
--- a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionDetailsDialogFragment.kt
+++ b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionDetailsDialogFragment.kt
@@ -26,7 +26,6 @@ import dagger.hilt.android.AndroidEntryPoint
import de.schildbach.wallet.WalletApplication
import de.schildbach.wallet.database.dao.DashPayProfileDao
import de.schildbach.wallet.service.PackageInfoProvider
-import de.schildbach.wallet.service.platform.work.TopupIdentityWorker
import de.schildbach.wallet.ui.TransactionResultViewModel
import de.schildbach.wallet.ui.compose_views.ComposeBottomSheet
import de.schildbach.wallet.ui.dashpay.transactions.PrivateMemoDialog
@@ -39,6 +38,8 @@ import de.schildbach.wallet_test.R
import de.schildbach.wallet_test.databinding.TransactionDetailsDialogBinding
import de.schildbach.wallet_test.databinding.TransactionResultContentBinding
import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.filterNotNull
import org.bitcoinj.core.Sha256Hash
import org.bitcoinj.core.Transaction
@@ -158,6 +159,14 @@ class TransactionDetailsDialogFragment : OffsetDialogFragment(R.layout.transacti
viewModel.sdkTxDetail.filterNotNull().observe(viewLifecycleOwner) { detail ->
transactionResultViewBinder.bindSdkDetail(detail)
+ // SDK top-up: swap the OP_RETURN row's pending label for
+ // "Platform credits" once the credits have landed.
+ lifecycleScope.launch {
+ viewModel.sdkTopUpCredited(txId)?.let { credited ->
+ transactionResultViewBinder.setSdkTopUpState(error = false, completed = credited)
+ }
+ }
+
viewModel.transactionIcon.observe(this) {
transactionResultViewBinder.setTransactionIcon(it)
}
@@ -181,50 +190,22 @@ class TransactionDetailsDialogFragment : OffsetDialogFragment(R.layout.transacti
dialog?.window!!.callback = UserInteractionAwareCallback(dialog?.window!!.callback, requireActivity())
}
- viewModel.topUpWork(txId).observe(this) { workData ->
- log.info("topup work data: {}", workData)
- try {
- val txIdString = workData.data?.outputData?.getString(TopupIdentityWorker.KEY_TOPUP_TX)
- log.info("txId from work matches viewModel: {} ==? {}", txIdString, txId)
-
- when (workData.status) {
- Status.LOADING -> {
- log.info(" loading: {}", workData.data?.outputData)
- }
-
- Status.SUCCESS -> {
- log.info(" success: {}", workData.data?.outputData)
- }
-
- Status.ERROR -> {
- log.info(" error: {}", workData.data?.outputData)
- viewModel.topUpError = true
- transactionResultViewBinder.setSentToReturn(
- viewModel.transaction.value?.versionShort ?: Transaction.SPECIAL_VERSION,
- viewModel.transaction.value?.type ?: Transaction.Type.TRANSACTION_ASSET_LOCK,
- viewModel.topUpError,
- viewModel.topUpComplete
- )
- }
-
- Status.CANCELED -> {
- log.info(" cancel: {}", workData.data?.outputData)
- }
- }
- } catch (e: Exception) {
- log.error("error processing topup information", e)
- }
- }
viewModel.topUpStatus(txId).observe(this) { topUp ->
- viewModel.topUpComplete = topUp?.used() == true
- viewModel.transaction.value?.let {
- transactionResultViewBinder.setSentToReturn(
- it.versionShort,
- it.type,
- viewModel.topUpError,
- viewModel.topUpComplete
- )
+ lifecycleScope.launch {
+ // Legacy top-ups have a `topups` row; SDK top-ups don't —
+ // their credited state comes from the SDK's recovery queue
+ // (lock still queued = pending, gone = credited).
+ viewModel.topUpComplete = topUp?.used() == true ||
+ (topUp == null && viewModel.sdkTopUpCredited(txId) == true)
+ viewModel.transaction.value?.let {
+ transactionResultViewBinder.setSentToReturn(
+ it.versionShort,
+ it.type,
+ viewModel.topUpError,
+ viewModel.topUpComplete
+ )
+ }
}
}
}
diff --git a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultActivity.kt b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultActivity.kt
index d170038865..035bc1ae26 100644
--- a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultActivity.kt
+++ b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultActivity.kt
@@ -26,6 +26,8 @@ import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.launch
import de.schildbach.wallet.ui.main.MainActivity
import de.schildbach.wallet.data.UsernameSearchResult
@@ -33,7 +35,6 @@ import de.schildbach.wallet.ui.dashpay.transactions.PrivateMemoDialog
import dagger.hilt.android.AndroidEntryPoint
import de.schildbach.wallet.database.dao.DashPayProfileDao
import de.schildbach.wallet.database.entity.DashPayProfile
-import de.schildbach.wallet.service.platform.work.TopupIdentityWorker
import de.schildbach.wallet.ui.LockScreenActivity
import de.schildbach.wallet.ui.TransactionResultViewModel
import de.schildbach.wallet.ui.compose_views.ComposeBottomSheet
@@ -204,6 +205,14 @@ class TransactionResultActivity : LockScreenActivity() {
// private memo) is txid-keyed and works unchanged.
viewModel.sdkTxDetail.filterNotNull().observe(this) { detail ->
transactionResultViewBinder.bindSdkDetail(detail)
+
+ // SDK top-up: swap the OP_RETURN row's pending label for
+ // "Platform credits" once the credits have landed.
+ lifecycleScope.launch {
+ viewModel.sdkTopUpCredited(txId)?.let { credited ->
+ transactionResultViewBinder.setSdkTopUpState(error = false, completed = credited)
+ }
+ }
contentBinding.openExplorerCard.setOnClickListener {
viewOnExplorerByTxId(detail.txIdDisplayHex)
}
@@ -221,48 +230,21 @@ class TransactionResultActivity : LockScreenActivity() {
}
}
- viewModel.topUpWork(txId).observe(this) { workData ->
- log.info("topup work data: {}", workData)
- try {
- val txIdString = workData.data?.outputData?.getString(TopupIdentityWorker.KEY_TOPUP_TX)
- log.info("txId from work matches viewModel: {} ==? {}", txIdString, txId)
-
- when (workData.status) {
- Status.LOADING -> {
- log.info(" loading: {}", workData.data?.outputData)
- }
-
- Status.SUCCESS -> {
- log.info(" success: {}", workData.data?.outputData)
- }
-
- Status.ERROR -> {
- log.info(" error: {}", workData.data?.outputData)
- viewModel.topUpError = true
- transactionResultViewBinder.setSentToReturn(
- viewModel.transaction.value?.versionShort ?: Transaction.SPECIAL_VERSION,
- viewModel.transaction.value?.type ?:Transaction.Type.TRANSACTION_ASSET_LOCK,
- viewModel.topUpError,
- viewModel.topUpComplete
- ) }
-
- Status.CANCELED -> {
- log.info(" cancel: {}", workData.data?.outputData)
- }
- }
- } catch (e: Exception) {
- log.error("error processing topup information", e)
- }
- }
viewModel.topUpStatus(txId).observe(this) { topUp ->
- viewModel.topUpComplete = topUp?.used() == true
- transactionResultViewBinder.setSentToReturn(
- viewModel.transaction.value?.versionShort ?: Transaction.SPECIAL_VERSION,
- viewModel.transaction.value?.type ?: Transaction.Type.TRANSACTION_ASSET_LOCK,
- viewModel.topUpError,
- viewModel.topUpComplete
- )
+ lifecycleScope.launch {
+ // Legacy top-ups have a `topups` row; SDK top-ups don't —
+ // their credited state comes from the SDK's recovery queue
+ // (lock still queued = pending, gone = credited).
+ viewModel.topUpComplete = topUp?.used() == true ||
+ (topUp == null && viewModel.sdkTopUpCredited(txId) == true)
+ transactionResultViewBinder.setSentToReturn(
+ viewModel.transaction.value?.versionShort ?: Transaction.SPECIAL_VERSION,
+ viewModel.transaction.value?.type ?: Transaction.Type.TRANSACTION_ASSET_LOCK,
+ viewModel.topUpError,
+ viewModel.topUpComplete
+ )
+ }
}
}
diff --git a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt
index 1a459dc787..e7895ae1e5 100644
--- a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt
+++ b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt
@@ -36,6 +36,7 @@ import coil.transform.RoundedCornersTransformation
import org.dash.wallet.common.ui.components.MerchantNameIcon
import de.schildbach.wallet.Constants
import de.schildbach.wallet.database.entity.DashPayProfile
+import de.schildbach.wallet.service.platform.sdk.AssetLockKind
import de.schildbach.wallet.service.platform.sdk.L1TxUiStatus
import de.schildbach.wallet.service.platform.sdk.SdkTxDetail
import de.schildbach.wallet.service.platform.sdk.assetLockTitleRes
@@ -121,6 +122,8 @@ class TransactionResultViewBinder(
private var inputAddresses: List = listOf()
private var outputAddresses: List = listOf()
private var outputAssetLocks = listOf()
+ /** Non-null after [bindSdkDetail] of a Platform-funding asset lock. */
+ private var sdkAssetLockKind: AssetLockKind? = null
fun bind(
tx: Transaction,
@@ -269,6 +272,7 @@ class TransactionResultViewBinder(
binding.transactionTitle.setTextColor(ContextCompat.getColor(context, R.color.dash_blue))
// A Platform-funding asset lock (upgrade / top-up / invite) surfaces
// its "…Fee" title instead of the generic "Amount Sent".
+ sdkAssetLockKind = detail.assetLockKind
binding.transactionTitle.text = detail.assetLockKind
?.let { context.getText(assetLockTitleRes(it)) }
?: context.getText(R.string.transaction_details_amount_sent)
@@ -347,7 +351,14 @@ class TransactionResultViewBinder(
binding.transactionOutputOpReturnsContainer,
false
) as TextView
- opReturnView.text = "OP RETURN"
+ // An SDK-era top-up's OP_RETURN is the Platform-credits burn —
+ // label it like the dashj path does, not as a raw script. The
+ // credited state is refreshed by [setSdkTopUpState].
+ opReturnView.text = if (sdkAssetLockKind == AssetLockKind.TOPUP) {
+ context.getString(R.string.platform_credits_not_transferred)
+ } else {
+ "OP RETURN"
+ }
binding.transactionOutputOpReturnsContainer.addView(opReturnView)
}
}
@@ -664,6 +675,28 @@ class TransactionResultViewBinder(
}
}
+ /**
+ * Refresh the Platform-credits row of an SDK-era top-up bound via
+ * [bindSdkDetail] once its credited state is known (lock gone from the
+ * SDK's recovery queue = credited). No-op for non-top-up details.
+ */
+ fun setSdkTopUpState(error: Boolean, completed: Boolean) {
+ if (sdkAssetLockKind != AssetLockKind.TOPUP) return
+ binding.transactionOutputOpReturnsContainer.removeAllViews()
+ binding.transactionOutputOpReturnsContainer.isVisible = true
+ val opReturnView = LayoutInflater.from(context).inflate(
+ R.layout.transaction_result_address_row,
+ binding.transactionOutputOpReturnsContainer,
+ false
+ ) as TextView
+ opReturnView.text = when {
+ error -> context.getString(R.string.platform_credits_error)
+ completed -> context.getString(R.string.platform_credits)
+ else -> context.getString(R.string.platform_credits_not_transferred)
+ }
+ binding.transactionOutputOpReturnsContainer.addView(opReturnView)
+ }
+
fun setSentToReturn(
transactionVersion: Int,
transactionType: Transaction.Type,
diff --git a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkDashPayWritesTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkDashPayWritesTest.kt
index 11ee7717c3..b9aef2a08a 100644
--- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkDashPayWritesTest.kt
+++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkDashPayWritesTest.kt
@@ -239,6 +239,44 @@ class SdkDashPayWritesTest {
assertSame(liveShape, (result as SdkWriteResult.NotBroadcast).cause)
}
+ @Test
+ fun classify_typedShortfalls_areNotBroadcast_withRetryableReasons() {
+ // The int19+ AAR line raises TYPED shortfall errors where older
+ // lines used WalletOperation message strings. Both types must land
+ // on the NAMED retryable reasons the MAX top-up's one-shot
+ // fee-adjusted retry keys off — a typed shortfall classified as
+ // Ambiguous (or as an unnamed NotBroadcast reason) silently disables
+ // that retry, which is exactly how the first live MAX test failed
+ // on a message-shape mismatch. The classification must hold even if
+ // a future engine empties the message text: the TYPE alone decides.
+ val typedShapes = mapOf(
+ DashSdkError.PlatformWallet.CoreInsufficientFunds(
+ "Insufficient funds: available 100000, required 200000"
+ ) to REASON_PRE_BROADCAST_BUILD_SHORTFALL,
+ DashSdkError.PlatformWallet.AssetLockInsufficientFunds(
+ "asset lock coin selection is short: available 58999510 duffs, " +
+ "required 58999510 duffs"
+ ) to REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION,
+ // Message drift armor: same types, unrecognizable message.
+ DashSdkError.PlatformWallet.CoreInsufficientFunds(
+ ""
+ ) to REASON_PRE_BROADCAST_BUILD_SHORTFALL,
+ DashSdkError.PlatformWallet.AssetLockInsufficientFunds(
+ "future engine wording"
+ ) to REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION
+ )
+ for ((error, expectedReason) in typedShapes) {
+ val result = classifyBroadcastFailure(error)
+ assertTrue(
+ "${error.javaClass.simpleName}(${error.message}) must be NotBroadcast",
+ result is SdkWriteResult.NotBroadcast
+ )
+ result as SdkWriteResult.NotBroadcast
+ assertEquals(expectedReason, result.reason)
+ assertSame(error, result.cause)
+ }
+ }
+
@Test
fun classify_shieldedBuildInputValidation_isNotBroadcast() {
// The live invite-claim failure: a pre-v13 0.3 DASH invite note was
diff --git a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryServiceTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryServiceTest.kt
new file mode 100644
index 0000000000..ef9c7cff40
--- /dev/null
+++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryServiceTest.kt
@@ -0,0 +1,265 @@
+/*
+ * 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.platform.sdk
+
+import kotlinx.coroutines.runBlocking
+import org.dashfoundation.dashsdk.errors.DashSdkError
+import org.dashfoundation.dashsdk.wallet.TrackedAssetLock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Host-JVM tests for the top-up recovery drain (#1520 item 3 / MO-998):
+ * the pass resumes exactly the resumable top-up locks, contains per-lock
+ * failures, treats already-consumed as terminal, and the checkTopUps
+ * trigger predicate never boots the SDK. No native calls; the recovery
+ * surface is faked via [SdkTopUpRecoverySource].
+ */
+class SdkTopUpRecoveryServiceTest {
+
+ private val walletId = "cd".repeat(32)
+ private val identityId = ByteArray(32) { 7 }
+ private val newBalance = 42_000_000_000L
+
+ private class FakeSource(
+ var boundWalletId: () -> String? = { null },
+ var recoveryLocks: () -> List = { emptyList() },
+ var onResume: (TrackedAssetLock) -> Long = { 0L }
+ ) : SdkTopUpRecoverySource {
+ var boundCalls = 0
+ var resumeCalls = 0
+ var lastIdentityId: ByteArray? = null
+ val resumedLocks = mutableListOf()
+
+ override suspend fun boundWalletIdOrNull(): String? {
+ boundCalls++
+ return boundWalletId()
+ }
+
+ override suspend fun trackedRecoveryLocks(walletIdHex: String): List =
+ recoveryLocks()
+
+ override suspend fun resumeTopUp(
+ walletIdHex: String,
+ identityId: ByteArray,
+ lock: TrackedAssetLock
+ ): Long {
+ resumeCalls++
+ lastIdentityId = identityId
+ resumedLocks += lock
+ return onResume(lock)
+ }
+ }
+
+ private fun lock(
+ fundingType: TrackedAssetLock.FundingType,
+ firstByte: Byte = 1,
+ status: TrackedAssetLock.Status = TrackedAssetLock.Status.BROADCAST
+ ) = TrackedAssetLock(
+ outpointTxid = ByteArray(32) { if (it == 0) firstByte else 0 },
+ outpointVout = 0,
+ fundingType = fundingType,
+ status = status,
+ registrationIndex = 0,
+ instantLockPresent = false,
+ chainLockHeight = 0
+ )
+
+ private fun service(
+ source: FakeSource,
+ identity: suspend () -> ByteArray? = { identityId },
+ sdkStarted: Boolean = true
+ ) = SdkTopUpRecoveryService(
+ source = source,
+ identityIdBytes = identity,
+ sdkIsStarted = { sdkStarted }
+ )
+
+ // ── drainPendingTopUps ────────────────────────────────────────────────
+
+ @Test
+ fun drain_unboundWallet_isNothingToDo() {
+ val source = FakeSource(boundWalletId = { null })
+ val report = runBlocking { service(source).drainPendingTopUps() }
+ assertEquals(TopUpDrainReport.NOTHING_TO_DO, report)
+ assertFalse(report.retryNeeded)
+ assertEquals(0, source.resumeCalls)
+ }
+
+ @Test
+ fun drain_bindLookupFailure_isSurfaceUnavailable_retryNeeded() {
+ val source = FakeSource(boundWalletId = { throw IllegalStateException("bootstrap failed") })
+ val report = runBlocking { service(source).drainPendingTopUps() }
+ assertTrue(report.surfaceUnavailable)
+ assertTrue(report.retryNeeded)
+ }
+
+ @Test
+ fun drain_emptySurface_isNothingToDo() {
+ val source = FakeSource(boundWalletId = { walletId }, recoveryLocks = { emptyList() })
+ val report = runBlocking { service(source).drainPendingTopUps() }
+ assertEquals(TopUpDrainReport.NOTHING_TO_DO, report)
+ assertEquals(0, source.resumeCalls)
+ }
+
+ @Test
+ fun drain_listFailure_isSurfaceUnavailable_noResumeCalls() {
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { throw IllegalStateException("FFI unavailable") }
+ )
+ val report = runBlocking { service(source).drainPendingTopUps() }
+ assertTrue(report.surfaceUnavailable)
+ assertEquals(0, source.resumeCalls)
+ }
+
+ @Test
+ fun drain_skipsRegistrationLocks_resumesBothTopUpTypes() {
+ val registration = lock(TrackedAssetLock.FundingType.IDENTITY_REGISTRATION, firstByte = 1)
+ val bound = lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP, firstByte = 2)
+ val unbound = lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP_NOT_BOUND, firstByte = 3)
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { listOf(registration, bound, unbound) },
+ onResume = { newBalance }
+ )
+ val report = runBlocking { service(source).drainPendingTopUps() }
+ assertEquals(TopUpDrainReport(pending = 2, resumed = 2, alreadyConsumed = 0, failed = 0), report)
+ assertFalse(report.retryNeeded)
+ assertEquals(listOf(bound, unbound), source.resumedLocks)
+ assertTrue(identityId.contentEquals(source.lastIdentityId!!))
+ }
+
+ @Test
+ fun drain_alreadyConsumed_isTerminal_notRetryable() {
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { listOf(lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP)) },
+ onResume = { throw DashSdkError.PlatformWallet.AssetLockAlreadyConsumed("already consumed") }
+ )
+ val report = runBlocking { service(source).drainPendingTopUps() }
+ assertEquals(TopUpDrainReport(pending = 1, resumed = 0, alreadyConsumed = 1, failed = 0), report)
+ assertFalse(report.retryNeeded)
+ }
+
+ @Test
+ fun drain_platformAlreadyUsedMessage_isTerminal_notRetryable() {
+ // Platform's own rejection is NOT the SDK's typed error: it arrives as
+ // a Generic protocol error reading "output N already completely used"
+ // (observed live). Treating it as retryable made WorkManager back off
+ // forever on a lock whose credits had already landed.
+ val wrapped = RuntimeException(
+ "SDK error",
+ DashSdkError.PlatformWallet.Generic(
+ 99,
+ "SDK error: Protocol error: Asset lock transaction " +
+ "8012039dc8500f0899171365986cdfd5982dc2967843236c0e8467ca566945ef " +
+ "output 0 already completely used"
+ )
+ )
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { listOf(lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP)) },
+ onResume = { throw wrapped }
+ )
+ val report = runBlocking { service(source).drainPendingTopUps() }
+ assertEquals(TopUpDrainReport(pending = 1, resumed = 0, alreadyConsumed = 1, failed = 0), report)
+ assertFalse(report.retryNeeded)
+ }
+
+ @Test
+ fun drain_oneFailure_doesNotStopTheRest_andRequestsRetry() {
+ val failing = lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP, firstByte = 2)
+ val fine = lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP_NOT_BOUND, firstByte = 3)
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { listOf(failing, fine) },
+ onResume = { l ->
+ if (l === failing) throw DashSdkError.NetworkError("proof fetch timed out")
+ newBalance
+ }
+ )
+ val report = runBlocking { service(source).drainPendingTopUps() }
+ assertEquals(TopUpDrainReport(pending = 2, resumed = 1, alreadyConsumed = 0, failed = 1), report)
+ assertTrue(report.retryNeeded)
+ assertEquals(2, source.resumeCalls)
+ }
+
+ @Test
+ fun drain_locksButNoIdentity_countsAllFailed_noResumeCalls() {
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { listOf(lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP)) }
+ )
+ val report = runBlocking { service(source, identity = { null }).drainPendingTopUps() }
+ assertEquals(TopUpDrainReport(pending = 1, resumed = 0, alreadyConsumed = 0, failed = 1), report)
+ assertTrue(report.retryNeeded)
+ assertEquals(0, source.resumeCalls)
+ }
+
+ @Test
+ fun drain_identityLookupThrow_countsAllFailed_noResumeCalls() {
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { listOf(lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP)) }
+ )
+ val report = runBlocking {
+ service(source, identity = { throw IllegalStateException("db closed") }).drainPendingTopUps()
+ }
+ assertEquals(TopUpDrainReport(pending = 1, resumed = 0, alreadyConsumed = 0, failed = 1), report)
+ assertEquals(0, source.resumeCalls)
+ }
+
+ // ── hasPendingTopUpLocks ─────────────────────────────────────────────
+
+ @Test
+ fun hasPending_trueOnlyForTopUpTypes() {
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { listOf(lock(TrackedAssetLock.FundingType.IDENTITY_REGISTRATION)) }
+ )
+ assertFalse(runBlocking { service(source).hasPendingTopUpLocks() })
+ source.recoveryLocks = { listOf(lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP_NOT_BOUND)) }
+ assertTrue(runBlocking { service(source).hasPendingTopUpLocks() })
+ }
+
+ @Test
+ fun hasPending_containedOnFailure_andFalseWhenUnbound() {
+ val throwing = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { throw IllegalStateException("FFI unavailable") }
+ )
+ assertFalse(runBlocking { service(throwing).hasPendingTopUpLocks() })
+ val unbound = FakeSource(boundWalletId = { null })
+ assertFalse(runBlocking { service(unbound).hasPendingTopUpLocks() })
+ }
+
+ @Test
+ fun hasPending_neverBootsTheSdk_whenNotStarted() {
+ // The periodic-sync trigger must be a no-boot probe: SDK down ->
+ // false WITHOUT touching the source (which would ensureStarted()).
+ val source = FakeSource(
+ boundWalletId = { walletId },
+ recoveryLocks = { listOf(lock(TrackedAssetLock.FundingType.IDENTITY_TOP_UP)) }
+ )
+ assertFalse(runBlocking { service(source, sdkStarted = false).hasPendingTopUpLocks() })
+ assertEquals(0, source.boundCalls)
+ }
+}