Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
75f2c81
feat: background completion of interrupted SDK top-ups + stuck legacy…
HashEngineering Aug 3, 2026
e330bb0
feat: credited state for SDK top-ups in transaction details
HashEngineering Aug 4, 2026
a830598
feat: run the Buy Credits purchase as unique background work
HashEngineering Aug 4, 2026
f4d546d
refactor!: delete the dashj Buy Credits purchase path (Phase 2/3)
HashEngineering Aug 4, 2026
02ed1e6
fix: persist the credits explainer's shown flag when displayed, not o…
HashEngineering Aug 4, 2026
710f4f9
feat: inline button progress for Buy Credits instead of a blocking di…
HashEngineering Aug 4, 2026
4f8d5e5
refactor!: delete the legacy dashj top-up retry loops (Phase 2/3 item 5)
HashEngineering Aug 4, 2026
9b5628d
chore: sweep 17 dead imports left by the deleted dashj purchase path
HashEngineering Aug 4, 2026
648633f
fix: treat a Platform already-used rejection as terminal, not retryable
HashEngineering Aug 4, 2026
ae00621
fix: Buy Credits UI — explainer, button busy state, and Max refusal
HashEngineering Aug 4, 2026
f951e24
refactor: remove the last dashj references from BuyCreditsFragment
HashEngineering Aug 5, 2026
fe43bbc
feat: Buy Credits MAX via the Internal Transfer pattern — full balanc…
HashEngineering Aug 7, 2026
5c000e2
fix: review round 1 — scoped stale-work handling, inclusive floor, si…
HashEngineering Aug 11, 2026
5f0ad15
docs+chore: review round 2 — restore caveat on credited state, lifecy…
HashEngineering Aug 11, 2026
3c51d0e
fix: typed shortfall arms in classifyBroadcastFailure — retry survive…
HashEngineering Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStarted
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.dash.wallet.common.money.Coin
Expand Down Expand Up @@ -173,6 +174,7 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) {

binding.keyboardView.onKeyboardActionListener = keyboardActionListener
binding.continueBtn.setOnClickListener {
if (binding.continueProgress.isVisible) return@setOnClickListener
val dashAmount = binding.amountView.dashAmount
val fiatAmount = binding.amountView.fiatAmount
viewModel.onContinueEvent.value = Pair(dashAmount, fiatAmount)
Expand All @@ -189,6 +191,7 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) {
}

viewModel.canContinue.observe(viewLifecycleOwner) { canContinue ->
if (continueLoading) return@observe
binding.continueBtn.isEnabled = if (!didAuthorize && requirePinForBalance && !viewModel.blockContinue) {
viewModel.amount.value?.isPositive == true
} else {
Expand All @@ -214,6 +217,29 @@ class EnterAmountFragment : Fragment(R.layout.fragment_enter_amount) {
}
}

/**
* Show a progress circle on the continue button and DISABLE it — for
* hosts whose action runs asynchronously after the tap. The disabled
* state is sticky: [canContinue] emissions cannot re-enable the button
* while loading (that observer would otherwise flip it back on within
* milliseconds).
*/
fun setContinueLoading(loading: Boolean) {
continueLoading = loading
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.withStarted {
binding.continueProgress.isVisible = loading
binding.continueBtn.text = if (loading) "" else getString(R.string.button_continue)
// isEnabled alone gives the app's standard disabled look: the
// button theme already maps it to `disabledBackgroundColor`.
binding.continueBtn.isEnabled = !loading
}
}
}

/** True while [setContinueLoading] holds the button in its busy state. */
private var continueLoading = false

fun applyMaxAmount() {
lifecycleScope.launchWhenStarted {
onMaxAmountButtonClick()
Expand Down
24 changes: 19 additions & 5 deletions common/src/main/res/layout/fragment_enter_amount.xml
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,28 @@
android:layout_marginBottom="@dimen/enter_amount_keyboard_spacing"
app:nk_decSeparatorEnabled="true" />

<Button
android:id="@+id/continue_btn"
style="@style/Button.Primary.Large.Blue"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="25dp"
android:layout_marginHorizontal="15dp"
android:text="@string/button_continue" />
android:layout_marginHorizontal="15dp">

<Button
android:id="@+id/continue_btn"
style="@style/Button.Primary.Large.Blue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/button_continue" />

<ProgressBar
android:id="@+id/continue_progress"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:elevation="8dp"
android:indeterminateTint="@color/white"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

Expand Down
1 change: 1 addition & 0 deletions wallet/res/values/strings-dashpay.xml
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@
the asset-lock build can actually select (final, confirmed/InstantSend-
locked coins) do not. -->
<string name="buy_credits_funds_settling">You need at least %s spendable Dash for this top-up. Recently received or transferred funds may still be settling.</string>
<string name="buy_credits_below_minimum">Enter at least %s to buy credits.</string>

<string name="request_username_username_voting_message">+ what is username voting?</string>
<string name="request_username_character_requirement">Letters, numbers and hyphens only</string>
Expand Down
20 changes: 0 additions & 20 deletions wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
52 changes: 20 additions & 32 deletions wallet/src/de/schildbach/wallet/service/platform/TopUpRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import de.schildbach.wallet.database.entity.DashPayProfile
import de.schildbach.wallet.database.entity.Invitation
import de.schildbach.wallet.database.entity.TopUp
import de.schildbach.wallet.service.DashSystemService
import de.schildbach.wallet.service.platform.work.TopupIdentityWorker
import de.schildbach.wallet.service.platform.sdk.SdkTopUpRecoveryService
import de.schildbach.wallet.service.platform.work.ResumeTopUpsOperation
import de.schildbach.wallet.ui.dashpay.PlatformRepo
import de.schildbach.wallet_test.BuildConfig
import org.bitcoinj.core.Coin
Expand Down Expand Up @@ -83,7 +84,7 @@ import androidx.core.net.toUri
/**
* contains topup related functions that are used by:
* 1. [CreateIdentityService] to create an identity
* 2. [TopupIdentityWorker] to topup an identity
* 2. [checkTopUps] to retry/complete legacy top-ups
* 3. [SendInviteWorker] to create Invitations (dynamic link)
*/
interface TopUpRepository {
Expand Down Expand Up @@ -174,7 +175,8 @@ class TopUpRepositoryImpl @Inject constructor(
private val dashPayProfileDao: DashPayProfileDao,
private val invitationsDao: InvitationsDao,
private val dashPayConfig: DashPayConfig,
private val dashSystemService: DashSystemService
private val dashSystemService: DashSystemService,
private val sdkTopUpRecoveryService: SdkTopUpRecoveryService
) : TopUpRepository {
companion object {
private val log = LoggerFactory.getLogger(TopUpRepositoryImpl::class.java)
Expand Down Expand Up @@ -528,38 +530,24 @@ class TopUpRepositoryImpl @Inject constructor(
}
}

private var checkedPreviousTopUps = false

/**
* Phase 2/3 (MO-998): the legacy dashj retry loops are DELETED — the
* SDK's tracked-lock queue is the only top-up retry system. Uncredited
* dashj-era top-ups from before the migration are NOT retried by the
* app anymore; they become recoverable again when the SDK gains
* chain rediscovery of asset locks (the pending platform change), at
* which point they surface on the recovery queue below like any
* interrupted SDK top-up. Funds are never lost in the interim — the
* locks sit on chain, claimable by this wallet's keys.
*/
override suspend fun checkTopUps(aesKeyParameter: KeyParameter?) {
val topUps = topUpsDao.getUnused()
topUps.forEach { topUp ->
try {
val tx = walletDataProvider.wallet!!.getTransaction(topUp.txId)
val assetLockTx = authExtension.getAssetLockTransaction(tx)
topUpIdentity(assetLockTx, aesKeyParameter)
topUpsDao.insert(topUp.copy(creditedAt = System.currentTimeMillis()))
} catch (e: Exception) {
// swallow
}
}
// only check once per app start
if (!checkedPreviousTopUps) {
log.info("checking all topup transactions")
authExtension.topupFundingTransactions.forEach { assetLockTx ->
val topUp = topUpsDao.getByTxId(assetLockTx.txId)
if (topUp == null || topUp.notUsed()) {
val identity = topUp?.toUserId ?: identityRepository.blockchainIdentity!!.uniqueIdentifier.toString()
if (topUp == null) {
topUpsDao.insert(TopUp(assetLockTx.txId, identity))
}
try {
topUpIdentity(assetLockTx, platformRepo.getWalletEncryptionKey()!!)
} catch (e: Exception) {
log.info("problem executing topup for ${assetLockTx.txId}", e)
}
}
try {
if (sdkTopUpRecoveryService.hasPendingTopUpLocks()) {
ResumeTopUpsOperation(walletApplication).enqueue()
}
checkedPreviousTopUps = true
} catch (e: Exception) {
log.warn("failed to check/enqueue the SDK top-up drain", e)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,8 @@ internal const val TX_CONTEXT_CHAIN_LOCKED = 3
* resolvable stays excluded — the engine can't route what it can't
* attribute).
*/
internal const val ELIGIBLE_ASSET_LOCK_DUFFS_SQL =
"SELECT COALESCE(SUM(t.amount), 0) FROM txos t " +
"LEFT JOIN core_addresses ca ON ca.address = t.address " +
internal const val ELIGIBLE_ASSET_LOCK_PREDICATE_SQL =
"LEFT JOIN core_addresses ca ON ca.address = t.address " +
"JOIN accounts a ON a.id = COALESCE(t.accountId, ca.accountId) " +
"LEFT JOIN transactions tx ON tx.txid = t.txid " +
"WHERE t.walletId = ? " +
Expand All @@ -82,6 +81,24 @@ internal const val ELIGIBLE_ASSET_LOCK_DUFFS_SQL =
"OR tx.context IN ($TX_CONTEXT_INSTANT_SEND, $TX_CONTEXT_CHAIN_LOCKED)) " +
"AND a.accountType = 0 AND a.standardTag = 0 AND a.accountIndex = 0"

/** Eligible duffs: SUM over [ELIGIBLE_ASSET_LOCK_PREDICATE_SQL]. */
internal const val ELIGIBLE_ASSET_LOCK_DUFFS_SQL =
"SELECT COALESCE(SUM(t.amount), 0) FROM txos t " +
ELIGIBLE_ASSET_LOCK_PREDICATE_SQL

/**
* COUNT twin of [ELIGIBLE_ASSET_LOCK_DUFFS_SQL] — the number of UTXOs a
* fresh asset-lock build can select. Sizes the fee reserve a MAX
* ("spend everything") top-up withholds on its one adjusted retry: the fee
* is ~148 bytes per INPUT, and this is the exact input population, from the
* engine that will do the selecting. (dashj's spendableUtxoCount() is the
* wrong ruler here: it counts coins the asset lock can never select —
* CoinJoin, other accounts, non-final — and post-cutover it can be stale.)
*/
internal const val ELIGIBLE_ASSET_LOCK_UTXO_COUNT_SQL =
"SELECT COUNT(*) FROM txos t " +
ELIGIBLE_ASSET_LOCK_PREDICATE_SQL

/**
* Pure coverage predicate for the preflight (host-JVM testable): can
* [eligibleDuffs] of asset-lock-eligible funds cover a lock of
Expand Down Expand Up @@ -185,7 +202,12 @@ class SdkAssetLockFundingPreflight internal constructor(
* the predicate), or `null` when unavailable. Production wiring runs
* the SQL against the SDK's Room database.
*/
private val eligibleDuffsQuery: suspend () -> Long?
private val eligibleDuffsQuery: suspend () -> Long?,
/**
* COUNT twin of [eligibleDuffsQuery]: the eligible-UTXO population, for
* sizing a MAX top-up's fee reserve. `null` when unavailable.
*/
private val eligibleUtxoCountQuery: suspend () -> Int? = { null }
) {
@Inject
constructor(
Expand All @@ -198,6 +220,12 @@ class SdkAssetLockFundingPreflight internal constructor(
sdkService.databaseOrNull(),
sdkService.walletManagerOrNull()?.wallets?.value?.keys?.singleOrNull()
)
},
eligibleUtxoCountQuery = {
queryEligibleAssetLockUtxoCount(
sdkService.databaseOrNull(),
sdkService.walletManagerOrNull()?.wallets?.value?.keys?.singleOrNull()
)
}
)

Expand Down Expand Up @@ -229,6 +257,30 @@ class SdkAssetLockFundingPreflight internal constructor(
* `null` = no evidence either way — treat as fundable (fail open).
* A `false` is logged with the figures for on-device forensics.
*/
/**
* The number of UTXOs a fresh asset-lock build can select — the input
* population whose per-input bytes dominate the L1 fee. `null` = no
* evidence (pre-cutover, SDK unavailable, read failure); callers fall
* back to not adjusting rather than guessing.
*/
suspend fun eligibleAssetLockUtxoCountOrNull(): Int? {
val committed = try {
cutoverCommitted()
} catch (t: Throwable) {
if (t is CancellationException) throw t
log.warn("asset-lock funding preflight: cutover state read failed; no UTXO count", t)
return null
}
if (!committed) return null
return try {
eligibleUtxoCountQuery()
} catch (t: Throwable) {
if (t is CancellationException) throw t
log.warn("asset-lock funding preflight: UTXO count read failed", t)
null
}
}

suspend fun canFundAssetLockDuffs(requiredDuffs: Long): Boolean? {
val eligible = eligibleAssetLockFundingDuffsOrNull() ?: return null
val covers = assetLockFundingCovers(eligible, requiredDuffs)
Expand Down Expand Up @@ -262,6 +314,27 @@ class SdkAssetLockFundingPreflight internal constructor(
* rule, coinbase rows are excluded outright (conservative — can
* only under-count, never over-count).
*/
/**
* COUNT twin of [queryEligibleAssetLockDuffs] — how many UTXOs the
* asset-lock coin selection can draw on. `null` when the SDK database
* or wallet binding is unavailable.
*/
internal suspend fun queryEligibleAssetLockUtxoCount(
database: org.dashfoundation.dashsdk.persistence.DashDatabase?,
walletIdHex: String?
): Int? {
val db = database ?: return null
val walletId = walletIdHex?.let { walletIdFromHex(it) } ?: return null
return withContext(Dispatchers.IO) {
db.openHelper.readableDatabase.query(
androidx.sqlite.db.SimpleSQLiteQuery(
ELIGIBLE_ASSET_LOCK_UTXO_COUNT_SQL,
arrayOf<Any?>(walletId)
)
).use { cursor -> if (cursor.moveToFirst()) cursor.getInt(0) else 0 }
}
}

internal suspend fun queryEligibleAssetLockDuffs(
database: org.dashfoundation.dashsdk.persistence.DashDatabase?,
walletIdHex: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ sealed class SdkWriteResult<out T> {
* Kept as a top-level pure function so the table is unit-testable on the
* host JVM without any native or Android dependency.
*/
/**
* [classifyBroadcastFailure] reasons for the two PRE-BROADCAST funding
* shortfalls that are retryable with a smaller amount (nothing submitted,
* selection released). Named so retry logic — e.g. a MAX top-up's one-shot
* fee-adjusted retry — matches the classifier's own verdict instead of
* re-matching raw engine messages that differ per build path.
*/
internal const val REASON_PRE_BROADCAST_BUILD_SHORTFALL =
"pre-broadcast build failure (insufficient funds / coin selection)"
internal const val REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION =
"pre-broadcast asset-lock coin-selection failure"

internal fun classifyBroadcastFailure(t: Throwable): SdkWriteResult<Nothing> = when {
t is DashSdkError.InvalidParameter ||
t is DashSdkError.InvalidState ||
Expand Down Expand Up @@ -143,6 +155,20 @@ internal fun classifyBroadcastFailure(t: Throwable): SdkWriteResult<Nothing> = w
// replaces the auth window and gives this a typed error.
t.message?.contains("User not authenticated") == true ->
SdkWriteResult.NotBroadcast("signing failure (pre-broadcast): Keystore auth window expired", t)
// TYPED funding shortfalls — checked BEFORE the message arms because
// engine message text drifts across AAR lines while the type cannot.
// CoreInsufficientFunds (FFI 22) is the atomic Core selection;
// AssetLockInsufficientFunds (FFI 29) is the asset-lock coin selection
// (asset_lock/build.rs map_builder_error promotes every builder
// shortfall shape to it, including the zero-candidate NoUtxosAvailable).
// Both are raised while BUILDING, strictly pre-broadcast, nothing
// submitted and the selection released — retryable with a smaller
// amount. The message arms below stay as the fallback for AAR lines
// that still surface these as WalletOperation strings.
t is DashSdkError.PlatformWallet.CoreInsufficientFunds ->
SdkWriteResult.NotBroadcast(REASON_PRE_BROADCAST_BUILD_SHORTFALL, t)
t is DashSdkError.PlatformWallet.AssetLockInsufficientFunds ->
SdkWriteResult.NotBroadcast(REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION, t)
// Coin selection / insufficient funds happens during transaction BUILDING,
// strictly before any broadcast — nothing was submitted. Surfaced as a
// WalletOperation error carrying the reason in the message (observed live:
Expand All @@ -155,7 +181,7 @@ internal fun classifyBroadcastFailure(t: Throwable): SdkWriteResult<Nothing> = w
m.contains("transaction build failed") ||
m.contains("set_funding failed")
} == true ->
SdkWriteResult.NotBroadcast("pre-broadcast build failure (insufficient funds / coin selection)", t)
SdkWriteResult.NotBroadcast(REASON_PRE_BROADCAST_BUILD_SHORTFALL, t)
// Shielded note selection (rs-platform-wallet note_selection.rs) runs
// strictly BEFORE proof generation or broadcast — nothing was submitted
// and the selected notes are released. Surfaced as a WalletOperation
Expand All @@ -182,7 +208,7 @@ internal fun classifyBroadcastFailure(t: Throwable): SdkWriteResult<Nothing> = w
// real shape. Message-matched until the SDK exposes typed errors.
// Retryable with a smaller amount.
t.message?.contains("asset lock coin selection is short") == true ->
SdkWriteResult.NotBroadcast("pre-broadcast asset-lock coin-selection failure", t)
SdkWriteResult.NotBroadcast(REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION, t)
// The SDK's SPV client wasn't running when broadcast was attempted, so the
// tx never left the device (observed live: the interim shield pipeline
// broadcasts via the shadow SPV, which our recovery paths stop/reset — the
Expand Down
Loading
Loading