Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8c8e107
fix(receive): serve the receive address from the SDK engine, not the …
HashEngineering Sep 22, 2026
2f6c906
fix(receive): correct two stale docs and pin the rollback clear
HashEngineering Sep 23, 2026
b62303a
fix(receive): stamp the address cache with a binding generation
HashEngineering Sep 25, 2026
4f593cb
fix(shielded): unshield to the unadvertised chain, not the receive ad…
HashEngineering Sep 25, 2026
aa0c36f
fix(shielded): fail closed rather than fall back to the advertised ad…
HashEngineering Sep 25, 2026
5145394
fix(receive): revoke ownership eagerly and order address publications
HashEngineering Sep 26, 2026
4e8b6dc
fix(receive): answer every caller from the cache; own the sweep desti…
HashEngineering Sep 28, 2026
1b0bc96
fix(receive): keep sweep failures retryable; park a real read in the …
HashEngineering Sep 28, 2026
4b409be
fix(receive): fail closed after cutover instead of the frozen dashj a…
HashEngineering Sep 28, 2026
6148a02
fix(receive): repair six consumer paths the fail-closed change broke
HashEngineering Sep 29, 2026
2a7e673
fix(crowdnode): guard signup and linking when no account address exists
HashEngineering Sep 29, 2026
77f8c78
fix(crowdnode): report and retry a missing account address, don't fai…
HashEngineering Sep 29, 2026
5ab1c64
refactor(coinbase): bind the deposit address instead of force-unwrapp…
HashEngineering Sep 30, 2026
38f0d4c
fix(receive): refuse on COMMITTED ownership, not the pipeline flag
HashEngineering Oct 2, 2026
34933b5
fix(receive): revoke the binding before publishing disabled ownership
HashEngineering Oct 2, 2026
92fa089
fix(receive): fence EVERY completed read, not only the successful ones
HashEngineering Oct 2, 2026
766de7f
fix(receive): carry the binding identity with the self-transfer desti…
HashEngineering Oct 2, 2026
75b4dac
fix(coinbase): take the confirm single-flight on the tap, not in the …
HashEngineering Oct 2, 2026
211d329
style: drop a redundant !! and merge two stacked KDoc blocks
HashEngineering Oct 2, 2026
29c1d73
test(receive): drain the pipeline's first read instead of racing it
HashEngineering Oct 2, 2026
78b1104
fix(sdk): let nothing escape the bind-heal job
HashEngineering Oct 2, 2026
7c9dee4
fix(receive): never let a remembered "dashj owns it" authorise the fa…
HashEngineering Oct 2, 2026
2e86d46
fix(receive): order the receive snapshots at the engine read, not bef…
HashEngineering Oct 2, 2026
d69c61e
fix(receive): stop an older deactivation revoking a newer activation
HashEngineering Oct 2, 2026
a9629fb
fix(receive): read the ownership state so a failed read stays a failure
HashEngineering Oct 2, 2026
22ffb53
test(receive): prove the activation survives, and record what this te…
HashEngineering Oct 2, 2026
21a903d
test(receive): stub the ownership read the service actually calls
HashEngineering Oct 2, 2026
59ea6b9
fix(receive): never let a Receive failure dismiss the payments flow
HashEngineering Oct 2, 2026
8194dc7
fix(receive): retry the address read when Receive is selected
HashEngineering Oct 3, 2026
5e9c61e
fix(receive): revalidate the address instead of latching the first one
HashEngineering Oct 3, 2026
e6765fd
fix(receive): a held address is not a successful revalidation
HashEngineering Oct 3, 2026
a1f5ae7
fix(coinbase): release the confirm flight only to the attempt that ow…
HashEngineering Oct 3, 2026
373ca4b
fix(receive): decide the ownership no-op under the ownership mutex
HashEngineering Oct 3, 2026
66facee
test(cutover): route the cutover-state fixtures by caller, not by sub…
HashEngineering Oct 3, 2026
f4374a4
fix(receive): do not surface an address failure after the screen is gone
HashEngineering Oct 3, 2026
6c850d2
fix(topper): scope the click-owned address read to the originating view
HashEngineering Oct 3, 2026
4e4cedd
docs(sdk): correct the unadvertised-destination KDoc residual
HashEngineering Oct 4, 2026
3862d6c
docs(cutover): state the reservation primitive as verified, not reported
HashEngineering Oct 4, 2026
3f09fd1
docs(receive): stop the receive/unadvertised KDocs naming stale callers
HashEngineering Oct 5, 2026
1abb621
docs(sdk): correct the unshield collision-window figure with the meas…
HashEngineering Oct 5, 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
16 changes: 16 additions & 0 deletions common/src/main/java/org/dash/wallet/common/WalletDataProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ interface WalletDataProvider {
fun freshReceiveAddressString(): String
fun currentReceiveAddressString(): String

/**
* [freshReceiveAddressString] / [currentReceiveAddressString] answered by a
* LIVE read of whichever engine owns the key chain right now.
*
* Post-cutover the wallet module reads the Kotlin SDK engine's next UNUSED
* address over the FFI instead of the dashj key chain, which is HELD post-
* cutover and would serve an address the chain has already paid (SR-03 /
* D-003). Pre-cutover — and in these defaults — the dashj answers are
* unchanged.
*
* BLOCKING, like the plain accessors: use [freshReceiveAddressStringOffMain]
* / [currentReceiveAddressStringOffMain] from feature and integration code.
*/
fun freshReceiveAddressStringLive(): String = freshReceiveAddressString()
fun currentReceiveAddressStringLive(): String = currentReceiveAddressString()

/** Estimated wallet balance. */
fun getWalletBalance(): Dash

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,19 @@ import org.dash.wallet.common.transactions.filters.LockedTransaction
* `freshReceiveAddressString()` keep working unchanged.
*/
suspend fun WalletDataProvider.freshReceiveAddressStringOffMain(): String =
withContext(Dispatchers.IO) { freshReceiveAddressString() }
withContext(Dispatchers.IO) { freshReceiveAddressStringLive() }

/**
* The wallet's current receive address, read OFF the calling thread.
*
* Same seam and same reason as [freshReceiveAddressStringOffMain], for the
* callers that want the address the wallet is currently advertising rather than
* a newly issued one (Maya's refund/source address, the swap aggregator). The
* `Live` accessor underneath is what makes this the ENGINE's next unused address
* post-cutover instead of the held dashj chain's frozen pointer.
*/
suspend fun WalletDataProvider.currentReceiveAddressStringOffMain(): String =
withContext(Dispatchers.IO) { currentReceiveAddressStringLive() }

/** [WalletDataProvider.observeTotalBalance] as neutral [Dash] amounts. */
fun WalletDataProvider.observeTotalDashBalance(): Flow<Dash> = observeTotalBalance()
Expand Down
20 changes: 20 additions & 0 deletions common/src/main/java/org/dash/wallet/common/data/BaseConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,26 @@ abstract class BaseConfig(
return data.map { preferences -> preferences[key] }
}

/**
* [observe] WITHOUT [data]'s IOException rescue — an unreadable store is
* delivered as the THROW it was instead of arriving as an absent key.
*
* [data] catches IOException and emits [emptyPreferences], so a corrupt,
* truncated or unreadable preferences file reaches [observe] exactly as a
* key that was never written does: `null`. For a preference that is only a
* hint that is the right trade — the unset default is harmless. For one that
* AUTHORISES something it is the wrong one: the caller cannot tell "nobody
* ever set this" from "we could not find out", so it takes the unset default
* on a read that never succeeded.
*
* Callers that must keep those apart collect this instead. `null` still
* means ABSENT, exactly as in [observe]; a FAILED read arrives as the
* IOException, for the caller to fail closed on.
*/
open fun <T> observePreservingErrors(key: Preferences.Key<T>): Flow<T?> {
return context.dataStore.data.map { preferences -> preferences[key] }
}

open suspend fun <T> get(key: Preferences.Key<T>): T? {
return data.map { preferences -> preferences[key] }.first()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

package org.dash.wallet.common.services

/**
* No receive address can be served right now.
*
* Raised only POST-CUTOVER, when the SDK engine cannot answer and the wallet
* therefore has no address it is safe to advertise. It exists because the
* obvious fallback is actively wrong: the dashj key chain is HELD after the
* cutover, so its "current" pointer is frozen wherever the restore left it —
* index 0 on a fresh restore, an address the chain has ALREADY been paid on.
* Serving that is the SR-03 defect.
*
* Callers must surface a retry rather than substitute an address. The condition
* is transient by nature: it covers the window before the engine binds, a failed
* engine read, and the instant after a wallet wipe revokes the binding.
*/
class ReceiveAddressUnavailableException(
message: String = "no receive address is available yet"
) : IllegalStateException(message)
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ class ReceiveInfoView(context: Context, attrs: AttributeSet?) : ConstraintLayout
}
}

/**
* Stop advertising an address: the address line and the QR code are both
* emptied. Used when a receive address that was on screen can no longer be
* vouched for — a stale address is worse than none, because the wallet may
* already have been paid on it.
*/
fun clearInfo() {
if (address != null || amount != null) {
address = null
amount = null
refresh()
}
}

fun setProfile(username: String?, displayName: String?, avatar: String?, avatarHash: ByteArray?) {
this.username = username

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.dash.wallet.common.WalletDataProvider
import org.dash.wallet.common.freshReceiveAddressStringOffMain
import org.dash.wallet.common.services.ReceiveAddressUnavailableException
import org.dash.wallet.common.ui.viewBinding
import org.dash.wallet.common.util.Constants
import org.dash.wallet.features.exploredash.R
Expand All @@ -55,7 +57,18 @@ class ExploreTestNetFragment : Fragment(R.layout.fragment_explore_testnet) {
// Off-main: dashj's freshReceiveAddress() forces a synchronous
// full-wallet save — never run it on a click listener's thread.
viewLifecycleOwner.lifecycleScope.launch {
val receiveAddress = walletDataProvider.freshReceiveAddressStringOffMain()
val receiveAddress = try {
walletDataProvider.freshReceiveAddressStringOffMain()
} catch (ex: ReceiveAddressUnavailableException) {
// Copying the held dashj chain's frozen address would point
// the faucet at an already-paid address (SR-03).
Toast.makeText(
requireContext(),
org.dash.wallet.common.R.string.loading_error,
Toast.LENGTH_LONG
).show()
return@launch
}
val clipboardManager =
requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.dash.wallet.common.money.MoneyFormat
import org.dash.wallet.common.services.analytics.AnalyticsConstants
Expand All @@ -46,6 +47,8 @@ import org.dash.wallet.integrations.coinbase.viewmodels.CoinbaseBuyDashViewModel
import org.dash.wallet.integrations.coinbase.viewmodels.CoinbaseBuyUIState
import org.dash.wallet.integrations.coinbase.viewmodels.CoinbaseViewModel
import org.dash.wallet.integrations.coinbase.viewmodels.coinbaseViewModels
import org.dash.wallet.common.services.ReceiveAddressUnavailableException
import android.widget.Toast

@AndroidEntryPoint
class CoinbaseOrderReviewFragment : Fragment(R.layout.fragment_coinbase_order_review) {
Expand All @@ -57,6 +60,19 @@ class CoinbaseOrderReviewFragment : Fragment(R.layout.fragment_coinbase_order_re
).noCode().minDecimals(6).optionalDecimals()
private var onBackPressedCallback: OnBackPressedCallback? = null

/**
* The confirm coroutine, so [onResume] can tell "finished, the user came
* back from 2FA" from "still running".
*/
private var confirmJob: Job? = null

/**
* The id of the confirm attempt THIS fragment instance started, so the attempt
* can be released by name. The single-flight itself lives in the retained view
* model, which outlives this instance.
*/
private var confirmAttemptId: Long? = null

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

Expand All @@ -83,11 +99,66 @@ class CoinbaseOrderReviewFragment : Fragment(R.layout.fragment_coinbase_order_re
}

binding.confirmBtnContainer.setOnClickListener {
lifecycleScope.launch {
if (tryBuyDash()) {
val params = viewModel.getTransferDashParams()
val twoFaParams = CoinbaseTransactionParams(params, TransactionType.BuyDash)
safeNavigate(CoinbaseOrderReviewFragmentDirections.coinbaseBuyDashOrderReviewToTwoFaCode(twoFaParams))
// Taken SYNCHRONOUSLY, on the tap, BEFORE anything is launched. The
// destination read below blocks (post-cutover it waits for the SDK
// engine to bind, then takes the engine's wallet-manager lock) and
// nothing is on screen while it does, so a second tap used to start a
// second coroutine — and a second placeBuyOrder under its own
// idempotency UUID, which Coinbase bills as a second purchase.
// Launching first and disabling inside the coroutine would not help:
// the taps race in exactly the window before the first suspension.
val attemptId = viewModel.tryBeginConfirm() ?: return@setOnClickListener
confirmAttemptId = attemptId
binding.confirmBtnContainer.isEnabled = false
confirmJob = lifecycleScope.launch {
// The flight is owned by this attempt through address preparation,
// the purchase and the handoff to 2FA, and given back only where
// nothing was bought.
try {
// Acquire the destination BEFORE the purchase. tryBuyDash()
// submits placeBuyOrder() with a fresh UUID, so an address
// failure after it would leave a SUCCEEDED purchase with no
// transfer parameters and no submitted-order state — pressing
// confirm again would place a second, different order. Checking
// first means the only failure is a no-op.
val params = try {
viewModel.getTransferDashParams()
} catch (ex: ReceiveAddressUnavailableException) {
// Depositing to the held dashj chain's frozen address is the
// SR-03 defect; nothing has been bought yet, so this is safe
// to retry.
Toast.makeText(
requireContext(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Check lifecycle cancellation before displaying an address-read failure

Rotate or leave this screen while getTransferDashParams() is waiting for an unbound SDK wallet. The fragment's lifecycleScope is cancelled, but the underlying Thread.sleep/FFI work continues. When that work subsequently fails, ReceiveAddressUnavailableException can still reach this catch: withContext's cancellation check discards successful results, but does not replace an exception produced by its body. The handler then calls requireContext() after the fragment has detached, throwing an uncaught IllegalStateException instead of presenting a retry. ReceiveFragment and the Topper entry-point fragments contain the same newly added failure-handler pattern. Check coroutine cancellation before performing error UI, and use a still-attached, appropriate lifecycle owner/context. Add coverage for cancellation followed by a failed address read; the current confirmation tests model cancellation cleanup without exercising the fragment's requireContext() call.

source: gpt-6.1-sol (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (re-reviewed at f4374a46): Your Coinbase failure handler now calls toastIfStillAttached(), which checks coroutine activity and uses nullable context rather than requireContext(). AttachedUiTest also explicitly stages an exceptional withContext completion after cancellation, so the guard addresses the reported late-failure path.

org.dash.wallet.common.R.string.loading_error,
Toast.LENGTH_LONG
).show()
return@launch
}
if (tryBuyDash(attemptId)) {
// Bought: the operation is over, so this attempt stops
// owning the flight and the navigation latch takes over.
// Deliberately not simply released: the navigation
// transaction has not executed when this coroutine ends, so
// this view is still attached and still clickable — a queued
// tap would place the second order. [onResume] drops the
// latch if the user comes back to this screen.
viewModel.latchConfirmForNavigation(attemptId)
val twoFaParams = CoinbaseTransactionParams(params, TransactionType.BuyDash)
safeNavigate(
CoinbaseOrderReviewFragmentDirections
.coinbaseBuyDashOrderReviewToTwoFaCode(twoFaParams)
)
}
} finally {
// Covers the early return, a declined purchase AND an
// unexpected throw: ownership left behind by a failure path
// would leave the button dead for the rest of the screen's
// life. Names the attempt, so it is a no-op once the purchase
// handed off to the latch above, and — the case cancellation
// creates — once this attempt no longer owns the flight because
// the fragment was recreated while it was still parked in the
// destination read and a new attempt is now running.
releaseConfirm(attemptId)
}
}
}
Expand Down Expand Up @@ -158,15 +229,46 @@ class CoinbaseOrderReviewFragment : Fragment(R.layout.fragment_coinbase_order_re
transactionStateDialog.showNow(parentFragmentManager, "CoinBaseBuyDashDialog")
}

override fun onResume() {
super.onResume()
// Back from the 2FA screen (cancelled, or back-pressed) with no confirm
// running: the next tap is a new intent, so the latch the navigation kept
// is dropped HERE rather than in the coroutine, which ends while this
// view is still on screen. ONLY the latch: an attempt that is still
// running — this instance's predecessor, unwinding the destination read it
// was cancelled in — owns its flight and gives it back itself.
if (confirmJob?.isActive != true) {
viewModel.releaseConfirmNavigationLatch()

if (!viewModel.isConfirmBusy()) {
binding.confirmBtnContainer.isEnabled = true
}
}
}

/**
* Give the confirm single-flight back for [attemptId] and re-enable the
* button. The view can already be gone — [lifecycleScope] outlives it — so the
* button half is conditional, and it is also skipped while anything else still
* holds the flight.
*/
private fun releaseConfirm(attemptId: Long) {
viewModel.endConfirm(attemptId)

if (view != null && !viewModel.isConfirmBusy()) {
binding.confirmBtnContainer.isEnabled = true
}
}

private fun setNetworkState(hasInternet: Boolean) {
binding.networkStatusStub.isVisible = !hasInternet
binding.previewOfflineGroup.isVisible = hasInternet
}

private suspend fun tryBuyDash(): Boolean {
private suspend fun tryBuyDash(attemptId: Long): Boolean {
try {
AdaptiveDialog.withProgress(getString(R.string.loading), requireActivity()) {
viewModel.buyDash()
viewModel.buyDash(attemptId)
}
} catch (ex: Exception) {
viewModel.logEvent(AnalyticsConstants.Coinbase.BUY_ERROR)
Expand Down Expand Up @@ -202,5 +304,13 @@ class CoinbaseOrderReviewFragment : Fragment(R.layout.fragment_coinbase_order_re
override fun onDestroy() {
super.onDestroy()
onBackPressedCallback?.remove()
// Whatever this instance started is cancelled with its [lifecycleScope],
// but a cancelled attempt parked in the uncancellable destination read can
// take seconds to unwind. Give its ownership back now, so the recreated
// fragment can confirm straight away; the late `finally` then finds the
// attempt no longer owns the flight and leaves the new one alone. A no-op
// for an attempt that already bought — that one is held by the navigation
// latch, which only [onResume] drops.
confirmAttemptId?.let { viewModel.endConfirm(it) }
}
}
Loading
Loading