From 9e0468df1962f559e703560ecb5834e7ed8b56e6 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 3 Aug 2026 16:18:45 -0700 Subject: [PATCH 01/16] feat: background completion of interrupted SDK top-ups + stuck legacy top-up rescue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK top-up (SdkTransparentTopUp, post-cutover Buy Credits) is a fused build+credit call whose in-process resume gate only helps if the user re-enters the flow. This adds the restart-surviving half: - SdkTopUpRecoveryService: one pass lists the SDK's tracked top-up locks (the two resumable funding types) and resumes each from its persisted outpoint via resumeTopUpWithExistingAssetLock — Rust owns rebroadcast/proof/consumption, so a pass is idempotent and a crash mid-run or duplicate enqueue is harmless. Includes a no-boot pending check for the sync sweep (never starts the SDK just to probe). - ResumeTopUpsWorker/Operation: payload-free WorkManager job — no txid, no identity, and no wallet password in WorkManager's database (the legacy TopupIdentityWorker stores one); unique KEEP work, network constraint, exponential backoff. Enqueued when SdkTransparentTopUp reports an Ambiguous outcome and from the checkTopUps sweep whenever tracked locks are pending. - checkTopUps: documents the scoping invariant (the legacy scan only ever sees dashj-created top-ups — the SDK derives keys the dashj chain never watches; this body retires with the dashj engine), and re-announces legacy top-up txs that provably never reached the network (SELF + PENDING + zero broadcast peers — the port-9999 field failure) while dashj still owns the peer group. An unconfirmed lock also blocks the auto-cutover, so rescuing these directly shortens the dual-running period. - signAndSendAssetLock stays the pre-cutover dashj builder; its top-up key is now issued inside the dashj branch only, so no key index is burned when the flow does not reach the dashj build. Restore recovery is deliberately absent: it requires the SDK to record the top-up locks its chain scan already recognizes (platform ask on MO-998); once that lands, this worker completes rediscovered locks with no further wallet changes. 16 host tests. MO-998 / dashpay/dash-wallet#1520 Co-Authored-By: Claude Fable 5 --- .../service/platform/TopUpRepository.kt | 58 +++- .../platform/sdk/SdkTopUpRecoveryService.kt | 271 ++++++++++++++++++ .../platform/work/ResumeTopUpsOperation.kt | 58 ++++ .../platform/work/ResumeTopUpsWorker.kt | 93 ++++++ .../wallet/ui/send/BuyCreditsFragment.kt | 7 +- .../wallet/ui/send/BuyCreditsViewModel.kt | 12 +- .../wallet/ui/send/SendCoinsViewModel.kt | 8 +- .../service/platform/TopUpReannounceTest.kt | 104 +++++++ .../sdk/SdkTopUpRecoveryServiceTest.kt | 240 ++++++++++++++++ 9 files changed, 845 insertions(+), 6 deletions(-) create mode 100644 wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt create mode 100644 wallet/src/de/schildbach/wallet/service/platform/work/ResumeTopUpsOperation.kt create mode 100644 wallet/src/de/schildbach/wallet/service/platform/work/ResumeTopUpsWorker.kt create mode 100644 wallet/test/de/schildbach/wallet/service/platform/TopUpReannounceTest.kt create mode 100644 wallet/test/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryServiceTest.kt diff --git a/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt b/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt index e19ec5ce9d..5e811f9a29 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt @@ -31,6 +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.sdk.SdkTopUpRecoveryService +import de.schildbach.wallet.service.platform.work.ResumeTopUpsOperation import de.schildbach.wallet.service.platform.work.TopupIdentityWorker import de.schildbach.wallet.ui.dashpay.PlatformRepo import de.schildbach.wallet_test.BuildConfig @@ -86,6 +88,19 @@ import androidx.core.net.toUri * 2. [TopupIdentityWorker] to topup an identity * 3. [SendInviteWorker] to create Invitations (dynamic link) */ +/** + * Whether a legacy top-up's asset-lock tx looks NEVER-BROADCAST and should + * be re-announced: our own send (SELF), still building/pending, and no + * peer has ever reported it. Deliberately conservative — a tx any peer + * has seen propagates on dashj's own rebroadcast machinery; a confirmed + * or dead tx must not be touched. Pure for host tests. + */ +internal fun shouldReannounceLegacyTopUp(confidence: TransactionConfidence?): Boolean = + confidence != null && + confidence.confidenceType == TransactionConfidence.ConfidenceType.PENDING && + confidence.source == TransactionConfidence.Source.SELF && + confidence.numBroadcastPeers() == 0 + interface TopUpRepository { suspend fun createAssetLockTransaction( blockchainIdentity: BlockchainIdentity, @@ -174,7 +189,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) @@ -531,6 +547,20 @@ class TopUpRepositoryImpl @Inject constructor( private var checkedPreviousTopUps = false override suspend fun checkTopUps(aesKeyParameter: KeyParameter?) { + // Phase B (#1520 item 3 / MO-998): SDK-created top-up locks retry + // through the payload-free drain worker — the SDK's tracked-lock + // table is the queue, so this sweep only needs to notice it is + // non-empty and enqueue (KEEP: an existing run wins). Contained; + // the dashj legacy scan below is unaffected and keeps covering + // pre-SDK top-ups. + try { + if (sdkTopUpRecoveryService.hasPendingTopUpLocks()) { + ResumeTopUpsOperation(walletApplication).enqueue() + } + } catch (e: Exception) { + log.warn("failed to check/enqueue the SDK top-up drain", e) + } + val topUps = topUpsDao.getUnused() topUps.forEach { topUp -> try { @@ -543,9 +573,35 @@ class TopUpRepositoryImpl @Inject constructor( } } // only check once per app start + // + // Phase D scoping invariant (#1520 item 3 / MO-998): this legacy + // scan serves DASHJ-created top-ups only, by construction — an SDK + // top-up's credit output pays a key on the SDK's own derivation + // (m/9'/coin'/5'/2'/identity'/index), which is NOT in the dashj + // BLOCKCHAIN_IDENTITY_TOPUP chain, so the auth extension never + // classifies the bridged tx into [topupFundingTransactions]; nor + // does the SDK route write a `topup_table` row. SDK top-ups retry + // exclusively through [ResumeTopUpsWorker]. This whole body is the + // part that retires with the dashj engine once the last legacy + // pending top-up drains (or an SDK lock-adoption import lands). if (!checkedPreviousTopUps) { log.info("checking all topup transactions") authExtension.topupFundingTransactions.forEach { assetLockTx -> + // A legacy top-up whose L1 tx never reached the network sits + // stuck forever (observed in the field with P2P port 9999 + // blocked): dashj shows it pending but no peer ever saw it, + // and the platform transition below can only fail. Re-announce + // it while dashj still owns the peergroup — idempotent for + // anything already propagating, contained on failure. + try { + if (shouldReannounceLegacyTopUp(assetLockTx.confidence)) { + log.info("re-announcing unbroadcast legacy topup tx {}", assetLockTx.txId) + walletApplication.broadcastTransaction(assetLockTx) + } + } catch (e: Exception) { + log.warn("failed to re-announce legacy topup tx ${assetLockTx.txId}", e) + } + val topUp = topUpsDao.getByTxId(assetLockTx.txId) if (topUp == null || topUp.notUsed()) { val identity = topUp?.toUserId ?: identityRepository.blockchainIdentity!!.uniqueIdentifier.toString() 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..453f85ba0f --- /dev/null +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt @@ -0,0 +1,271 @@ +/* + * 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()) + +// ── 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 (t is DashSdkError.PlatformWallet.AssetLockAlreadyConsumed) { + // 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 + } + + 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/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/ui/send/BuyCreditsFragment.kt b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt index 1d9265170e..57d58e25cf 100644 --- a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt @@ -199,9 +199,10 @@ class BuyCreditsFragment : SendCoinsFragment() { 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) + // The topup key is issued inside signAndSendAssetLock's dashj + // branch only — the SDK route derives its own key, and issuing + // one here would burn an unused dashj chain index per SDK top-up. + val tx = viewModel.signAndSendAssetLock(editedAmount.toDashjCoin(), exchangeRate, checkBalance, maxSelected) buyCreditsViewModel.topUpTransaction = tx onSignAndSendPaymentSuccess(tx) diff --git a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt index b5cf7ca87f..c1f1bf58d0 100644 --- a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt @@ -9,6 +9,7 @@ import de.schildbach.wallet.database.entity.BlockchainIdentityConfig 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.ResumeTopUpsOperation import de.schildbach.wallet.service.platform.work.TopupIdentityOperation import de.schildbach.wallet.ui.dashpay.PlatformRepo import de.schildbach.wallet.ui.dashpay.utils.DashPayConfig @@ -134,7 +135,16 @@ class BuyCreditsViewModel @Inject constructor( 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) + val result = sdkTransparentTopUp.topUp(identityId, amountDuffs) + if (result is SdkWriteResult.Ambiguous) { + // If the fused top-up DID reach the L1 broadcast, the asset lock + // is Rust-tracked and resumable — the restart-surviving drain + // worker completes it in the background (idempotent no-op when + // nothing was actually broadcast). The executor's in-process + // sticky refusal still prevents a user-driven double attempt. + ResumeTopUpsOperation(walletApplication).enqueue() + } + result } /** diff --git a/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt b/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt index 52e6d1bbd8..b8315af5aa 100644 --- a/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt @@ -315,11 +315,16 @@ class SendCoinsViewModel @Inject constructor( transaction } + /** + * The PRE-CUTOVER dashj top-up build+broadcast. Post-cutover this is + * never reached: BuyCreditsFragment routes through + * [de.schildbach.wallet.service.platform.sdk.SdkTransparentTopUp] + * (the SDK's resume-gated, fused topUpFromCore) instead. + */ suspend fun signAndSendAssetLock( editedAmount: Coin, exchangeRate: ExchangeRate?, checkBalance: Boolean, - key: ECKey, emptyWallet: Boolean ): Transaction = withContext(Dispatchers.IO) { _state.postValue(State.SENDING) @@ -328,6 +333,7 @@ class SendCoinsViewModel @Inject constructor( } val finalPaymentIntent = basePaymentIntent.mergeWithEditedValues(editedAmount.toNeutralCoin(), null) + val key = getNextKey() val transaction = try { var finalSendRequest = sendCoinsTaskRunner.createAssetLockSendRequest( basePaymentIntent.mayEditAmount(), diff --git a/wallet/test/de/schildbach/wallet/service/platform/TopUpReannounceTest.kt b/wallet/test/de/schildbach/wallet/service/platform/TopUpReannounceTest.kt new file mode 100644 index 0000000000..098d0af818 --- /dev/null +++ b/wallet/test/de/schildbach/wallet/service/platform/TopUpReannounceTest.kt @@ -0,0 +1,104 @@ +/* + * 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 + +import io.mockk.every +import io.mockk.mockk +import org.bitcoinj.core.TransactionConfidence +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Host-JVM tests for the Phase D never-broadcast predicate: the + * once-per-start legacy top-up scan re-announces ONLY our own pending + * transactions that no peer has ever reported. + */ +class TopUpReannounceTest { + + private fun confidence( + type: TransactionConfidence.ConfidenceType, + source: TransactionConfidence.Source, + broadcastPeers: Int + ): TransactionConfidence = mockk { + every { confidenceType } returns type + every { this@mockk.source } returns source + every { numBroadcastPeers() } returns broadcastPeers + } + + @Test + fun neverBroadcastSelfTx_isReannounced() { + assertTrue( + shouldReannounceLegacyTopUp( + confidence( + TransactionConfidence.ConfidenceType.PENDING, + TransactionConfidence.Source.SELF, + broadcastPeers = 0 + ) + ) + ) + } + + @Test + fun seenByAnyPeer_isLeftToDashjRebroadcast() { + assertFalse( + shouldReannounceLegacyTopUp( + confidence( + TransactionConfidence.ConfidenceType.PENDING, + TransactionConfidence.Source.SELF, + broadcastPeers = 1 + ) + ) + ) + } + + @Test + fun confirmedDeadOrNetworkSourced_isNeverTouched() { + assertFalse( + shouldReannounceLegacyTopUp( + confidence( + TransactionConfidence.ConfidenceType.BUILDING, + TransactionConfidence.Source.SELF, + broadcastPeers = 0 + ) + ) + ) + assertFalse( + shouldReannounceLegacyTopUp( + confidence( + TransactionConfidence.ConfidenceType.DEAD, + TransactionConfidence.Source.SELF, + broadcastPeers = 0 + ) + ) + ) + assertFalse( + shouldReannounceLegacyTopUp( + confidence( + TransactionConfidence.ConfidenceType.PENDING, + TransactionConfidence.Source.NETWORK, + broadcastPeers = 0 + ) + ) + ) + } + + @Test + fun missingConfidence_isNeverTouched() { + assertFalse(shouldReannounceLegacyTopUp(null)) + } +} 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..e270f5baaa --- /dev/null +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryServiceTest.kt @@ -0,0 +1,240 @@ +/* + * 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_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) + } +} From e2bbd0e5490e713841b63cd88e1e88de10a5a8c1 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 3 Aug 2026 20:02:14 -0700 Subject: [PATCH 02/16] feat: credited state for SDK top-ups in transaction details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK top-ups have no topups-table row, so the detail screens now derive credited state from the SDK itself: a lock still in the recovery queue is pending, gone is credited (SdkTopUpRecoveryService.isTopUpPending + TransactionResultViewModel.sdkTopUpCredited). The OP_RETURN output row of an SDK top-up is labeled 'Platform credits (…)' like the dashj path always did, instead of the raw script name. The legacy TopupIdentityWorker status observers (log-only plus one error flag) are dropped from both screens — that worker is deleted in a follow-up commit. Co-Authored-By: Claude Fable 5 --- .../platform/sdk/SdkTopUpRecoveryService.kt | 24 +++++++ .../wallet/ui/TransactionResultViewModel.kt | 21 +++++- .../TransactionDetailsDialogFragment.kt | 67 +++++++------------ .../transactions/TransactionResultActivity.kt | 64 +++++++----------- .../TransactionResultViewBinder.kt | 34 +++++++++- 5 files changed, 122 insertions(+), 88 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt index 453f85ba0f..63b71fb795 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkTopUpRecoveryService.kt @@ -261,6 +261,30 @@ class SdkTopUpRecoveryService internal constructor( 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. + */ + 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 diff --git a/wallet/src/de/schildbach/wallet/ui/TransactionResultViewModel.kt b/wallet/src/de/schildbach/wallet/ui/TransactionResultViewModel.kt index 7deeb94779..c9942efea1 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,17 @@ 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. + */ + 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/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..2cec557291 100644 --- a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt +++ b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionResultViewBinder.kt @@ -121,6 +121,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: de.schildbach.wallet.service.platform.sdk.AssetLockKind? = null fun bind( tx: Transaction, @@ -269,6 +271,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 +350,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 == de.schildbach.wallet.service.platform.sdk.AssetLockKind.TOPUP) { + context.getString(R.string.platform_credits_not_transferred) + } else { + "OP RETURN" + } binding.transactionOutputOpReturnsContainer.addView(opReturnView) } } @@ -664,6 +674,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 != de.schildbach.wallet.service.platform.sdk.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, From 153f05ad986b40f14381d67163e72a7c3c7035dd Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 3 Aug 2026 20:02:14 -0700 Subject: [PATCH 03/16] feat: run the Buy Credits purchase as unique background work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lock screen cancelled a purchase mid-flow in testing: the fragment ran the SDK top-up in its own lifecycle scope. PerformTopUpWorker now owns the purchase — input is the amount only (no wallet password, no txid in WorkManager's database; the SDK signs internally), one unique KEEP work name so double-taps attach instead of buying twice, and the screen just observes WorkInfo (spinner/success/failure/unconfirmed). A rerun after process death cannot double-pay: SdkTransparentTopUp's resume gate matches the already-broadcast lock first, and an unconfirmed outcome hands off to ResumeTopUpsWorker. Buy Credits is SDK-only from the go handler down (dashj branch removed from the fragment). Co-Authored-By: Claude Fable 5 --- .../platform/work/PerformTopUpOperation.kt | 63 +++++++ .../platform/work/PerformTopUpWorker.kt | 104 +++++++++++ .../wallet/ui/send/BuyCreditsFragment.kt | 170 ++++++------------ .../wallet/ui/send/BuyCreditsViewModel.kt | 111 ++++-------- 4 files changed, 256 insertions(+), 192 deletions(-) create mode 100644 wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpOperation.kt create mode 100644 wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpWorker.kt 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..8df877c33b --- /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) { + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(PerformTopUpWorker.KEY_AMOUNT_DUFFS to amountDuffs)) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + WorkManager.getInstance(application) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP, request) + } + + /** Drop finished runs so a past outcome cannot re-fire on the next visit. */ + fun prune() { + WorkManager.getInstance(application).pruneWork() + } +} 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..e53cb20c9d --- /dev/null +++ b/wallet/src/de/schildbach/wallet/service/platform/work/PerformTopUpWorker.kt @@ -0,0 +1,104 @@ +/* + * 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.work.BaseWorker +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 +) : BaseWorker(context, parameters) { + companion object { + private val log = LoggerFactory.getLogger(PerformTopUpWorker::class.java) + const val KEY_AMOUNT_DUFFS = "PerformTopUpWorker.AMOUNT_DUFFS" + const val KEY_NEW_BALANCE = "PerformTopUpWorker.NEW_BALANCE" + const val KEY_AMBIGUOUS = "PerformTopUpWorker.AMBIGUOUS" + } + + 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 result = try { + sdkTransparentTopUp.topUp(identityId, amountDuffs) + } catch (t: Throwable) { + if (t is CancellationException) throw t + log.error("top-up threw unexpectedly", t) + SdkWriteResult.Ambiguous(t) + } + return when (result) { + is SdkWriteResult.Broadcast -> { + log.info("top-up of {} duffs credited; new balance {}", amountDuffs, 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/ui/send/BuyCreditsFragment.kt b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt index 57d58e25cf..9fc193ad15 100644 --- a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsFragment.kt @@ -12,6 +12,10 @@ 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 @@ -159,106 +163,33 @@ class BuyCreditsFragment : SendCoinsFragment() { 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 - } - + /** + * 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. + */ + private suspend fun handleGo() { 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) - } - // The topup key is issued inside signAndSendAssetLock's dashj - // branch only — the SDK route derives its own key, and issuing - // one here would burn an unused dashj chain index per SDK top-up. - val tx = viewModel.signAndSendAssetLock(editedAmount.toDashjCoin(), exchangeRate, checkBalance, 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) - } - + handleSdkTopUp(editedAmount.toDashjCoin().value) viewModel.resetState() } } - 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() - } - } - /** - * 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) { @@ -280,34 +211,51 @@ 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) + observeTopUpWork() + } - 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 var topUpProgressDialog: AdaptiveDialog? = null + + 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 -> { + if (topUpProgressDialog == null) { + topUpProgressDialog = AdaptiveDialog.progress(getString(R.string.send_coins_sending_msg)) + .also { it.show(parentFragmentManager, "buy_credits_sdk_topup") } + } + } + WorkInfo.State.SUCCEEDED -> { + dismissTopUpProgress() + buyCreditsViewModel.pruneTopUpWork() + log.info( + "SDK top-up credited; new balance {}", + work.outputData.getLong(PerformTopUpWorker.KEY_NEW_BALANCE, -1) + ) + onSdkTopUpSuccess() + } + WorkInfo.State.FAILED -> { + dismissTopUpProgress() + buyCreditsViewModel.pruneTopUpWork() + 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 -> dismissTopUpProgress() } } } + private fun dismissTopUpProgress() { + topUpProgressDialog?.dismissAllowingStateLoss() + topUpProgressDialog = null + } + private fun onSdkTopUpSuccess() { // The SDK fuses the asset-lock build with the Platform top-up // registration, so there is no dashj funding tx to return to a calling diff --git a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt index c1f1bf58d0..581ed8c3cb 100644 --- a/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/send/BuyCreditsViewModel.kt @@ -2,62 +2,46 @@ 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.SdkAssetLockFundingPreflight -import de.schildbach.wallet.service.platform.sdk.SdkTransparentTopUp -import de.schildbach.wallet.service.platform.sdk.SdkWriteResult -import de.schildbach.wallet.service.platform.work.ResumeTopUpsOperation -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 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. * @@ -94,57 +78,22 @@ 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]. */ - suspend fun isCutoverCommitted(): Boolean = sdkTransparentTopUp.isCutoverCommitted() + fun startTopUp(amountDuffs: Long) { + PerformTopUpOperation(walletApplication).enqueue(amountDuffs) + } - /** - * 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. - */ - 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") - val result = sdkTransparentTopUp.topUp(identityId, amountDuffs) - if (result is SdkWriteResult.Ambiguous) { - // If the fused top-up DID reach the L1 broadcast, the asset lock - // is Rust-tracked and resumable — the restart-surviving drain - // worker completes it in the background (idempotent no-op when - // nothing was actually broadcast). The executor's in-process - // sticky refusal still prevents a user-driven double attempt. - ResumeTopUpsOperation(walletApplication).enqueue() - } - result + /** Live status of the unique purchase work (empty until first use). */ + fun topUpWorkStatus(): LiveData> = + PerformTopUpOperation.status(walletApplication) + + /** Forget finished runs so an old outcome cannot re-fire on re-entry. */ + fun pruneTopUpWork() { + PerformTopUpOperation(walletApplication).prune() } /** @@ -159,4 +108,4 @@ class BuyCreditsViewModel @Inject constructor( suspend fun canFundTopUp(amountDuffs: Long): Boolean = withContext(Dispatchers.IO) { assetLockFundingPreflight.canFundAssetLockDuffs(amountDuffs) ?: true } -} \ No newline at end of file +} From 4931c4663ddd726c41622bd32edc6ff3aa8a3439 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 3 Aug 2026 20:02:14 -0700 Subject: [PATCH 04/16] refactor!: delete the dashj Buy Credits purchase path (Phase 2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Management rule: a replaced function loses its dashj implementation in the same PR. Deleted: signAndSendAssetLock + the isAssetLock dry-run arm (SendCoinsViewModel), createAssetLockSendRequest (SendCoinsTaskRunner), getNextKey (topup-chain key issuance), and TopupIdentityWorker/Operation (the legacy per-txid retry job that stored the wallet password in WorkManager). Pre-cutover Buy Credits now refuses cleanly via SdkTransparentTopUp's fail-closed gate instead of building with dashj. checkTopUps recovery and the topups table STAY — they credit legacy top-ups and can only retire once the SDK can adopt locks it did not create (MO-998 upstream ask). MO-998 / dashpay/dash-wallet#1520 Co-Authored-By: Claude Fable 5 --- .../wallet/payments/SendCoinsTaskRunner.kt | 20 -- .../service/platform/TopUpRepository.kt | 3 +- .../platform/sdk/SdkTransparentTopUp.kt | 2 +- .../platform/work/TopupIdentityOperation.kt | 173 ------------------ .../platform/work/TopupIdentityWorker.kt | 124 ------------- .../wallet/ui/send/SendCoinsViewModel.kt | 97 +--------- 6 files changed, 9 insertions(+), 410 deletions(-) delete mode 100644 wallet/src/de/schildbach/wallet/service/platform/work/TopupIdentityOperation.kt delete mode 100644 wallet/src/de/schildbach/wallet/service/platform/work/TopupIdentityWorker.kt 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 5e811f9a29..c8b790ee86 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt @@ -33,7 +33,6 @@ import de.schildbach.wallet.database.entity.TopUp import de.schildbach.wallet.service.DashSystemService import de.schildbach.wallet.service.platform.sdk.SdkTopUpRecoveryService import de.schildbach.wallet.service.platform.work.ResumeTopUpsOperation -import de.schildbach.wallet.service.platform.work.TopupIdentityWorker import de.schildbach.wallet.ui.dashpay.PlatformRepo import de.schildbach.wallet_test.BuildConfig import org.bitcoinj.core.Coin @@ -85,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) */ /** 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..9cb32ba2ad 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 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/send/SendCoinsViewModel.kt b/wallet/src/de/schildbach/wallet/ui/send/SendCoinsViewModel.kt index b8315af5aa..0bc63cceae 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 { @@ -149,8 +148,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 +251,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,65 +309,6 @@ class SendCoinsViewModel @Inject constructor( transaction } - /** - * The PRE-CUTOVER dashj top-up build+broadcast. Post-cutover this is - * never reached: BuyCreditsFragment routes through - * [de.schildbach.wallet.service.platform.sdk.SdkTransparentTopUp] - * (the SDK's resume-gated, fused topUpFromCore) instead. - */ - suspend fun signAndSendAssetLock( - editedAmount: Coin, - exchangeRate: ExchangeRate?, - checkBalance: Boolean, - 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 key = getNextKey() - 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()) @@ -460,7 +395,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, @@ -468,22 +403,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) { @@ -709,12 +634,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 - } } From 89df638edfbcdbffe38d0a353132bf1e0afd72f8 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 3 Aug 2026 20:08:24 -0700 Subject: [PATCH 05/16] fix: persist the credits explainer's shown flag when displayed, not on dismissal The write lived only in the dismiss() override, so any close that bypasses it (swipe-down, tap outside, back) never saved the flag and the explainer re-appeared on every Buy Credits tap; even the button path wrote during teardown, racing the dialog's destruction. Mark it shown the moment it is displayed instead. Co-Authored-By: Claude Fable 5 --- .../ui/more/tools/WhatAreCreditsDialogFragment.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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..13ce8dbc9a 100644 --- a/wallet/src/de/schildbach/wallet/ui/more/tools/WhatAreCreditsDialogFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/more/tools/WhatAreCreditsDialogFragment.kt @@ -41,16 +41,20 @@ class WhatAreCreditsDialogFragment : OffsetDialogFragment(R.layout.dialog_what_a dismiss() } binding.homeIndicator.isVisible = !showCloseButton - } - - override fun dismiss() { + // Mark the explainer as seen as soon as it is DISPLAYED. Writing on + // dismissal missed every non-button close (swipe-down, tap outside, + // back), which bypasses the dismiss() override — the flag was never + // persisted and the dialog re-appeared on every Buy Credits tap. lifecycleScope.launch { viewModel.setCreditsExplained() - onDismissAction?.invoke() - super.dismiss() } } + override fun dismiss() { + onDismissAction?.invoke() + super.dismiss() + } + fun show(fragmentActivity: FragmentActivity, onDismissAction: () -> Unit) { this.onDismissAction = onDismissAction show(fragmentActivity) From c6f35c7c68b3a775a35b943818602b07ed4ea5a2 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 3 Aug 2026 20:23:30 -0700 Subject: [PATCH 06/16] feat: inline button progress for Buy Credits instead of a blocking dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Send button shows a progress circle (taps swallowed) only until the purchase worker reports it has handed the buy to the SDK (a progress marker set just before the SDK call) — from that point the outcome no longer needs the screen: success still auto-closes, failures still dialog, and the recovery worker owns anything interrupted. The shared enter-amount component gains an optional setContinueLoading(loading) overlay. Co-Authored-By: Claude Fable 5 --- .../ui/enter_amount/EnterAmountFragment.kt | 12 ++++++++++ .../main/res/layout/fragment_enter_amount.xml | 24 +++++++++++++++---- .../platform/work/PerformTopUpWorker.kt | 6 +++++ .../wallet/ui/more/ToolsFragment.kt | 2 +- .../wallet/ui/send/BuyCreditsFragment.kt | 23 +++++++----------- 5 files changed, 47 insertions(+), 20 deletions(-) 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..80d5e16c01 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 @@ -173,6 +173,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) @@ -214,6 +215,17 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) { } } + /** + * Show a progress circle on (and swallow taps of) the continue button — + * for hosts whose action continues in the background after the tap. + */ + fun setContinueLoading(loading: Boolean) { + lifecycleScope.launchWhenStarted { + binding.continueProgress.isVisible = loading + binding.continueBtn.text = if (loading) "" else getString(R.string.button_continue) + } + } + 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" /> -