Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9e0468d
feat: background completion of interrupted SDK top-ups + stuck legacy…
HashEngineering Aug 3, 2026
e2bbd0e
feat: credited state for SDK top-ups in transaction details
HashEngineering Aug 4, 2026
153f05a
feat: run the Buy Credits purchase as unique background work
HashEngineering Aug 4, 2026
4931c46
refactor!: delete the dashj Buy Credits purchase path (Phase 2/3)
HashEngineering Aug 4, 2026
89df638
fix: persist the credits explainer's shown flag when displayed, not o…
HashEngineering Aug 4, 2026
c6f35c7
feat: inline button progress for Buy Credits instead of a blocking di…
HashEngineering Aug 4, 2026
a896913
refactor!: delete the legacy dashj top-up retry loops (Phase 2/3 item 5)
HashEngineering Aug 4, 2026
f0d4239
chore: sweep 17 dead imports left by the deleted dashj purchase path
HashEngineering Aug 4, 2026
21f008d
fix: treat a Platform already-used rejection as terminal, not retryable
HashEngineering Aug 4, 2026
68e6f53
fix: Buy Credits UI — explainer, button busy state, and Max refusal
HashEngineering Aug 4, 2026
78bf5c6
refactor: remove the last dashj references from BuyCreditsFragment
HashEngineering Aug 5, 2026
a45d71b
feat: Buy Credits MAX via the Internal Transfer pattern — full balanc…
HashEngineering Aug 7, 2026
56859d9
fix: review round 1 — scoped stale-work handling, inclusive floor, si…
HashEngineering Aug 11, 2026
cc35bd6
docs+chore: review round 2 — restore caveat on credited state, lifecy…
HashEngineering Aug 11, 2026
27004d2
fix: typed shortfall arms in classifyBroadcastFailure — retry survive…
HashEngineering Aug 11, 2026
7d0b392
fix: post-rebase reconciliation with the phase1 tip + review nit
HashEngineering Aug 17, 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 @@ -454,6 +454,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 @@ -210,6 +210,31 @@ data class AssetLockFundingEvidence(
val unclassifiedDuffs: Long
)

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

/**
* The COUNT with the SAME [lockCount]-parameterized finality term and spine
* as [eligibleAssetLockDuffsSql] (args: walletId, then the wire-order txid
* blobs) — the two queries must count/sum the SAME population, or the MAX
* fee reserve gets sized from a different UTXO set than the one the sum
* (and the engine's selection) sees.
*/
internal fun eligibleAssetLockUtxoCountSql(lockCount: Int): String =
"SELECT COUNT(*) $ASSET_LOCK_TXO_JOINS " +
"WHERE t.walletId = ? " +
"AND $UNSPENT_SELECTABLE_TERMS " +
"AND ${mirrorFinalTermSql(lockCount)} " +
"AND $BIP44_ACCOUNT_0_TERM"

/**
* Pure coverage predicate for the preflight (host-JVM testable): can
* [eligibleDuffs] of asset-lock-eligible funds cover a lock of
Expand Down Expand Up @@ -359,7 +384,13 @@ class SdkAssetLockFundingPreflight internal constructor(
* `null` when unavailable. Production wiring runs the two SQL passes
* against the SDK's Room database.
*/
private val evidenceQuery: suspend () -> AssetLockFundingEvidence?
private val evidenceQuery: suspend () -> AssetLockFundingEvidence?,
/**
* COUNT twin of [evidenceQuery]'s eligible sum: the eligible-UTXO
* population, for sizing a MAX top-up's fee reserve. `null` when
* unavailable.
*/
private val eligibleUtxoCountQuery: suspend () -> Int? = { null }
) {
@Inject
constructor(
Expand All @@ -386,6 +417,21 @@ class SdkAssetLockFundingPreflight internal constructor(
emptyList()
}
)
},
eligibleUtxoCountQuery = {
queryEligibleAssetLockUtxoCount(
sdkService.databaseOrNull(),
sdkService.walletManagerOrNull()?.wallets?.value?.keys?.singleOrNull(),
// Same lock evidence as evidenceQuery, so the count's
// population cannot diverge from the sum's.
persistedLockTxidsHex = try {
instantSendLockDao.getMostRecentTxIds(MAX_PREFLIGHT_LOCK_TXIDS)
} catch (t: Throwable) {
if (t is CancellationException) throw t
log.warn("persisted IS-lock read failed; UTXO count evaluates without lock evidence", t)
emptyList()
}
)
}
)

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

suspend fun canFundAssetLockDuffs(requiredDuffs: Long): Boolean? {
val evidence = assetLockFundingEvidenceOrNull() ?: return null
val verdict = assetLockFundingVerdict(evidence, requiredDuffs)
Expand Down Expand Up @@ -458,6 +528,36 @@ class SdkAssetLockFundingPreflight internal constructor(
* rule, coinbase rows are excluded outright (conservative — can
* only under-count, never over-count).
*/
/**
* COUNT twin of [queryAssetLockFundingEvidence]'s eligible sum — how
* many UTXOs the asset-lock coin selection can draw on, counted over
* the SAME spine and the SAME persisted-IS-lock finality evidence so
* the population cannot diverge from the sum's. `null` when the SDK
* database or wallet binding is unavailable.
*/
internal suspend fun queryEligibleAssetLockUtxoCount(
database: org.dashfoundation.dashsdk.persistence.DashDatabase?,
walletIdHex: String?,
persistedLockTxidsHex: List<String> = emptyList()
): Int? {
val db = database ?: return null
val walletId = walletIdHex?.let { walletIdFromHex(it) } ?: return null
val lockBlobs = persistedLockTxidsHex
.take(MAX_PREFLIGHT_LOCK_TXIDS)
.mapNotNull { hexToBytesOrNull(it.lowercase())?.takeIf { b -> b.size == 32 }?.reversedArray() }
return withContext(Dispatchers.IO) {
val args = ArrayList<Any?>(1 + lockBlobs.size)
args.add(walletId)
args.addAll(lockBlobs)
db.openHelper.readableDatabase.query(
androidx.sqlite.db.SimpleSQLiteQuery(
eligibleAssetLockUtxoCountSql(lockBlobs.size),
args.toTypedArray()
)
).use { cursor -> if (cursor.moveToFirst()) cursor.getInt(0) else 0 }
}
}

internal suspend fun queryAssetLockFundingEvidence(
database: org.dashfoundation.dashsdk.persistence.DashDatabase?,
walletIdHex: String?,
Expand Down
Loading
Loading