Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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)
98 changes: 98 additions & 0 deletions common/src/main/java/org/dash/wallet/common/ui/AttachedUi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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.ui

import android.app.Activity
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive

/**
* Run [block] against [host], but only while this coroutine is still running
* AND the host is still usable. Returns whether [block] ran.
*
* For the error handlers around the blocking receive-address reads. Those reads
* park on a `Thread.sleep` that does not answer cancellation (up to
* `CutoverUiDataService.BINDING_WAIT_MS` waiting for the SDK engine to bind), so
* seconds can pass between a screen going away and the read unwinding into its
* `catch`.
*
* Both checks are load-bearing, and neither implies the other:
*
* * CANCELLATION. A cancelled `withContext` discards a SUCCESSFUL result and
* resumes the caller with `CancellationException`, but an exception thrown by
* its body is delivered as ITSELF — `DispatchedTask.run` consults the job
* only when the resumed result carries no exception. So a `catch` for a
* failed read runs even though the job was cancelled while the read was
* parked, and the handler would otherwise show a toast for a screen the user
* has left. A view-scoped coroutine is also cancelled when only the VIEW is
* destroyed — navigating forward with the fragment kept on the back stack —
* and there `Fragment.getContext` is still non-null, so this check is the
* only thing standing between that failure and a toast over the next screen.
* * ATTACHMENT. Cancellation is cooperative and merely requested; a
* fragment-scoped coroutine resuming seconds after `onDestroy` finds the
* fragment detached, and `requireContext()` / `requireActivity()` throw
* `IllegalStateException` there. Passing the NULLABLE accessor turns that
* crash into a no-op.
*
* Deliberately NOT `ensureActive()`. Every caller's handler ends at the UI it is
* guarding — there is nothing further to abort — so raising a
* `CancellationException` out of a `catch` block would replace plain control
* flow with a throw for no gain. Callers that DO have work after the handler
* already end it with their own `return`.
*/
suspend fun <T : Any> runIfStillAttached(host: T?, block: (T) -> Unit): Boolean {
if (!currentCoroutineContext().isActive) {
return false
}

val attached = host ?: return false
block(attached)

return true
}

/**
* Toast a failure that surfaced inside a coroutine — shown only while this
* fragment is still attached and the coroutine still running. Returns whether
* it was shown.
*
* Takes [Fragment.getContext], never `requireContext()`: that throw is exactly
* the crash this exists to prevent. Deliberately NOT the application context
* either — these are "the wallet has no address yet, try again" prompts that
* belong to the screen the user is on, so a user who has left should not be
* interrupted by one.
*/
suspend fun Fragment.toastIfStillAttached(
@StringRes messageRes: Int,
duration: Int = Toast.LENGTH_LONG
): Boolean = runIfStillAttached(context) { Toast.makeText(it, messageRes, duration).show() }

/**
* [toastIfStillAttached] for an activity. An activity context is never nulled,
* so "still usable" is instead "not already going away" — and the cancellation
* check is what keeps a toast from arriving after the user left.
*/
suspend fun Activity.toastIfStillAttached(
@StringRes messageRes: Int,
duration: Int = Toast.LENGTH_LONG
): Boolean = runIfStillAttached(takeIf { !it.isFinishing && !it.isDestroyed }) {
Toast.makeText(it, messageRes, duration).show()
}
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
149 changes: 149 additions & 0 deletions common/src/test/java/org/dash/wallet/common/ui/AttachedUiTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* 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.ui

import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.dash.wallet.common.services.ReceiveAddressUnavailableException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* The guard every receive-address failure handler goes through.
*
* The hazard it exists for is specific. A live receive-address read parks on an
* uncancellable `Thread.sleep` (up to five seconds waiting for the SDK engine to
* bind) inside `withContext(Dispatchers.IO)`, so the screen that started it can
* be long gone by the time the read unwinds. Cancelling a `withContext` discards
* a SUCCESSFUL result and resumes the caller with `CancellationException` — but
* an exception thrown by its BODY is delivered as itself. The failure handler
* therefore runs on a dead screen, and `requireContext()` there throws
* `IllegalStateException` instead of offering the retry.
*
* [aCancelledWithContextStillDeliversItsBodysException] pins that library
* behaviour, because everything else here is only worth doing if it holds.
*/
class AttachedUiTest {

/** A stand-in for `Fragment.getContext()`: present while attached. */
private class Host

/**
* Run [handle] as the failure handler of a read that is PARKED when its
* coroutine is cancelled and only then fails — the exact ordering the real
* engine read produces, and the one that makes the handler run on a screen
* that is already gone.
*
* Returns once the coroutine has fully unwound, so nothing a caller asserts
* afterwards can race it.
*/
private fun runFailureAfterCancellation(handle: suspend () -> Unit): Boolean {
val parked = CountDownLatch(1)
val release = CountDownLatch(1)
val handlerRan = AtomicBoolean(false)
val scope = CoroutineScope(Dispatchers.Default + Job())

val job = scope.launch {
try {
withContext(Dispatchers.IO) {
parked.countDown()
check(release.await(AWAIT_SECONDS, TimeUnit.SECONDS)) { "the read was never released" }
throw ReceiveAddressUnavailableException()
}
} catch (ex: ReceiveAddressUnavailableException) {
handlerRan.set(true)
handle()
}
}

assertTrue("the read must have parked", parked.await(AWAIT_SECONDS, TimeUnit.SECONDS))
// Rotation, a back press, the page leaving the pager: the scope is
// cancelled while the read cannot answer cancellation.
job.cancel()
release.countDown()
runBlocking { job.join() }

return handlerRan.get()
}

@Test
fun aCancelledWithContextStillDeliversItsBodysException() {
// The premise. If this ever stops holding, the guard below is dead code
// rather than wrong — but so is the reasoning in every handler that uses
// it, which is why it is asserted rather than assumed.
assertTrue(
"a cancelled withContext whose body throws must deliver that throw to the catch",
runFailureAfterCancellation { }
)
}

@Test
fun aFailureArrivingAfterCancellationSurfacesNothing() {
val surfaced = AtomicInteger(0)
// Deliberately an ATTACHED host. This models the window the real
// fragments sit in: `onDestroy` has cancelled the scope but `onDetach`
// has not yet nulled the context, so the host check cannot help and only
// the cancellation check can.
val host = Host()

val handlerRan = runFailureAfterCancellation {
runIfStillAttached(host) { surfaced.incrementAndGet() }
}

// Both halves matter. If the handler did NOT run, the assertion below
// would pass for the wrong reason and cover nothing.
assertTrue("the failure handler must still run — that is the hazard", handlerRan)
assertEquals("but it must surface nothing on a cancelled coroutine", 0, surfaced.get())
}

@Test
fun aDetachedHostSurfacesNothing() = runBlocking {
val surfaced = AtomicInteger(0)

// What `Fragment.getContext()` returns once the fragment has detached,
// and where `requireContext()` throws instead.
assertFalse(runIfStillAttached(null) { surfaced.incrementAndGet() })
assertEquals(0, surfaced.get())
}

@Test
fun anAttachedHostOnALiveCoroutineSurfacesTheFailure() = runBlocking {
// The guard must not be a blanket "never show anything": the retry
// prompt is the whole point of failing closed.
val surfacedOn = AtomicReference<Host?>(null)
val host = Host()

assertTrue(runIfStillAttached(host) { surfacedOn.set(it) })
assertEquals(host, surfacedOn.get())
}

companion object {
private const val AWAIT_SECONDS = 10L
}
}
Loading
Loading