Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c97ecf3
fix(wallet): surface RecoveredFromChain asset locks on host resume paths
bfoss765 Aug 19, 2026
1f06fc5
fix(wallet): bound the asset-lock recovery waits that could pin a hos…
bfoss765 Aug 19, 2026
c017d88
fix(wallet): a rejected re-broadcast must not untrack, and bound the …
bfoss765 Aug 19, 2026
a21c55c
fix(wallet): RecoveredFromChain is proven-final and fundable in the K…
bfoss765 Aug 20, 2026
7768174
fix(wallet): surface and route resumable SHIELDED top-ups on both hosts
bfoss765 Aug 20, 2026
6f4506c
docs(wallet-ffi): a zero resume timeout selects the policy default, n…
bfoss765 Aug 20, 2026
3ab29ab
fix(example-apps): key shielded resume single-flighting on the operat…
bfoss765 Aug 20, 2026
19849eb
docs(wallet-ffi): carve the accepted Built re-broadcast out of the ze…
bfoss765 Aug 20, 2026
d46ae46
docs(wallet): record why reconcile degrades every proof-upgrade failu…
bfoss765 Aug 20, 2026
6ff9c1e
test(wallet): pin both promotion-failure arms of the reconciliation d…
bfoss765 Aug 21, 2026
fc62336
fix(wallet): consult the local proof before failing a rejected defens…
bfoss765 Aug 23, 2026
d03dcf7
fix(example-apps): mint a unique operation id per fresh shield
bfoss765 Aug 24, 2026
0558156
Merge branch 'v4.2-dev' into fix/asset-lock-recovery-hardening
HashEngineering Aug 27, 2026
e74f847
Merge branch 'v4.2-dev' into fix/asset-lock-recovery-hardening
HashEngineering Aug 27, 2026
2b7b85d
fix(wallet): do not report a rejected defensive re-broadcast as a rej…
shumkov Aug 27, 2026
70ee558
Merge branch 'v4.2-dev' into fix/asset-lock-recovery-hardening
HashEngineering Aug 27, 2026
a148a89
Merge remote-tracking branch 'origin/v4.2-dev' into w4422
shumkov Aug 28, 2026
3ea5e88
Merge branch 'v4.2-dev' into fix/asset-lock-recovery-hardening
shumkov Aug 28, 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 @@ -284,7 +284,12 @@ fun AppNavHost(
}

composable<ShieldedFund> { entry ->
ShieldedFundScreen(entry.toRoute<ShieldedFund>().walletIdHex, navController)
val route = entry.toRoute<ShieldedFund>()
ShieldedFundScreen(
walletIdHex = route.walletIdHex,
navController = navController,
resumeOutPointHex = route.resumeOutPointHex.takeIf { it.isNotEmpty() },
)
}

composable<ShieldedFundProgress> { entry ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,21 @@ import kotlinx.serialization.Serializable
/** Seed the shielded note pool (← `SeedShieldedPoolView.swift`). */
@Serializable data class SeedShieldedPool(val walletIdHex: String)

/** Shield funds from an asset lock (← `ShieldedFundFromAssetLockView.swift`). */
@Serializable data class ShieldedFund(val walletIdHex: String)
/**
* Shield funds from an asset lock (← `ShieldedFundFromAssetLockView.swift`).
*
* [resumeOutPointHex] carries the `<txid display hex>:<vout>` of an already-
* tracked orphan SHIELDED top-up lock (`fundingTypeRaw == 5`) when the
* screen is opened in RESUME mode — the shielded twin of
* [FundFromAssetLock.resumeOutPointHex], and the counterpart of the Swift
* view's `resumeFromLock` parameter. Empty means fresh-shield mode; nav args
* don't support a nullable `String`, so `""` stands in for "no lock to
* resume".
*/
@Serializable data class ShieldedFund(
val walletIdHex: String,
val resumeOutPointHex: String = "",
)

/**
* Live shielded-funding progress (← `ShieldedFundFromAssetLockProgressView.swift`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import androidx.navigation.NavHostController
import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity
import org.dashfoundation.example.di.LocalAppContainer
import org.dashfoundation.example.navigation.AddressFundProgress
import org.dashfoundation.example.navigation.FundFromAssetLock
import org.dashfoundation.example.services.assetlock.AddressFundFromAssetLockController
import org.dashfoundation.example.services.assetlock.AddressFundFromAssetLockController.Phase
import org.dashfoundation.example.util.hexToBytes
Expand Down Expand Up @@ -83,9 +82,16 @@ fun PendingAssetLockRow(
* IdentitiesHome list embeds. Port of
* `PendingPlatformFundFromAssetLocksList.swift`: merges two row sources —
* (1) [controllers], the live in-flight fundings from the coordinator, and
* (2) [resumableLocks], DB-backed orphan `AssetLockEntity` rows
* (`fundingTypeRaw == 4`, `statusRaw ∈ [1, 3]`) recovered after a crash
* between asset-lock broadcast and ST submission.
* (2) [resumableLocks], DB-backed orphan `AssetLockEntity` rows recovered
* after a crash between asset-lock broadcast and ST submission.
*
* The orphan half covers BOTH top-up funding types — `4`
* (AssetLockAddressTopUp) and `5` (AssetLockShieldedAddressTopUp) — because
* neither has any other recovery home: the identity screens admit only
* funding types `0..2`. Each row's Resume is routed by funding type through
* [resumeRouteFor]; a shielded lock has to reach the shielded resume FFI,
* not the platform-address one, so surfacing the row without routing it
* would only move the dead end one tap later.
*
* Anti-double-consume gate: if ANY controller is [Phase.InFlight]
* ([hasActiveFunding]), Resume is suppressed on every orphan row — the
Expand All @@ -110,17 +116,15 @@ fun PendingAssetLocksList(
)
controllers.forEach { PendingAssetLockRow(it, navController) }
resumableLocks.forEach { (walletIdHex, lock) ->
// Fail closed: a funding type with no resume flow on this
// surface keeps the non-interactive indicator rather than
// dispatching to a screen that would submit the wrong
// transition for it.
val route = resumeRouteFor(walletIdHex, lock)
ResumablePlatformFundFromAssetLockRow(
lock = lock,
hasActiveFunding = hasActiveFundingFor(walletIdHex),
onResume = {
navController.navigate(
FundFromAssetLock(
walletIdHex = walletIdHex,
resumeOutPointHex = lock.outPointHex,
),
)
},
hasActiveFunding = hasActiveFundingFor(walletIdHex) || route == null,
onResume = { route?.let { navController.navigate(it) } },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,58 @@ import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity

/**
* Asset-lock display helpers — port of `PersistentAssetLockDisplay.swift`.
* Consolidates the 0/1/2/3/4 `AssetLockStatus` discriminants (a protocol-
* Consolidates the 0..5 `AssetLockStatus` discriminants (a protocol-
* mirrored constant from the Rust side) into one place so the funding UIs
* don't re-implement the same `when` blocks. Extensions on
* [AssetLockEntity] so every list surface reads the status the same way.
*
* The status domain is NOT an ordered severity scale, and no predicate here
* may treat it as one. `4` (Consumed) is the terminal tombstone; `5`
* (RecoveredFromChain) sits above it numerically but is very much alive.
* Every predicate below therefore excludes `4` by name rather than by an
* upper bound.
*/

/**
* `true` when the lock should appear on the resumable "Pending Platform Top
* Ups" orphan surface at all — `statusRaw ∈ [1, 3]` (Broadcast through
* ChainLocked). Lower bar than [canFundIdentity]: a Broadcast (1) lock isn't
* submittable yet but the user should still see it (as "Waiting for
* InstantSend / ChainLock…") so a crash-recovery situation has visible
* continuity through the IS-lock arrival. Upper bound at 3 is load-bearing:
* status 4 (Consumed) is terminal and `resume_asset_lock` rejects it, so a
* Consumed row must never resurface as a perpetual-spinner dead end.
* Ups" orphan surface at all — `statusRaw ∈ [1, 3] ∪ {5}`. Lower bar than
* [canFundIdentity]: a Broadcast (1) lock isn't submittable yet but the user
* should still see it (as "Waiting for InstantSend / ChainLock…") so a
* crash-recovery situation has visible continuity through the IS-lock
* arrival.
*
* Excluding `4` (Consumed) is load-bearing: it is terminal and
* `resume_asset_lock` rejects it, so a Consumed row must never resurface as
* a perpetual-spinner dead end. That exclusion is deliberately NOT written
* as an upper bound of `3` — `5` (RecoveredFromChain) is what the restore
* scan and the chainlock-promotion path write for a lock with proven Core
* finality and unknown Platform-side consumption, and a contiguous `1..3`
* range silently dropped every one of them: a chain-locked top-up the user
* really funded appeared on no surface at all and read as lost funds.
* ← Swift `isVisibleAsResumable`.
*/
val AssetLockEntity.isVisibleAsResumable: Boolean
get() = statusRaw in 1..3
get() = statusRaw in 1..3 || statusRaw == 5

/**
* `true` when the lock has a usable IS-lock / chain-lock proof AND hasn't
* been consumed — `statusRaw == 2 || statusRaw == 3`. Only these can submit
* the funding ST immediately; Built (0) and Broadcast (1) still await
* finality. ← Swift `canFundIdentity`.
* been consumed — `statusRaw ∈ {2, 3, 5}`. Only these can submit the funding
* ST immediately; Built (0) and Broadcast (1) still await finality.
*
* `5` (RecoveredFromChain) qualifies: the restore scan and the
* chainlock-promotion path attach a real `ChainAssetLockProof` before
* writing that status, so Core-side finality is PROVEN and the lock is
* exactly as fundable as a ChainLocked (3) one. What is unknown for a `5` is
* whether Platform already consumed it — and Platform, not the client, is
* the arbiter of that: it rejects an already-spent outpoint with a typed
* error. A user-driven Resume is the surface allowed to ask. (Do not feed
* `5` into an automatic retry sweep — blind retries of historical locks are
* the failure mode the status exists to prevent.) Reading `5` as
* not-yet-final made the UI tell the user to wait for a finality that had
* already happened. ← Swift `canFundIdentity`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*/
val AssetLockEntity.canFundIdentity: Boolean
get() = statusRaw == 2 || statusRaw == 3
get() = statusRaw == 2 || statusRaw == 3 || statusRaw == 5

/**
* Human-readable status label. Mirrors the Rust-side `AssetLockStatus` enum.
Expand All @@ -44,6 +68,9 @@ val AssetLockEntity.statusLabel: String
2 -> "InstantSendLocked"
3 -> "ChainLocked"
4 -> "Consumed"
// Core finality proven, Platform-side consumption unknown.
// Rendered as "Unknown(5)" before this branch existed.
5 -> "RecoveredFromChain"
else -> "Unknown($statusRaw)"
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package org.dashfoundation.example.ui.funding

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity
import org.dashfoundation.example.navigation.FundFromAssetLock
import org.dashfoundation.example.navigation.ShieldedFund
import org.dashfoundation.example.util.hexToBytes

/**
* The two `AssetLockFundingType` discriminants that own the "Pending
* Platform Top Ups" recovery surface.
*
* The full domain is `0 IdentityRegistration, 1 IdentityTopUp,
* 2 IdentityTopUpNotBound, 3 IdentityInvitation, 4 AssetLockAddressTopUp,
* 5 AssetLockShieldedAddressTopUp`. Types `0..2` recover on the identity
* screens (`IdentitiesContentView.crossWalletResumableLocks` and its Kotlin
* counterpart, both of which admit only `0..2`); `3` is a bearer voucher
* consumed exclusively by the invitation reclaim flow. That leaves `4` and
* `5` with no recovery home other than this surface — and `5` had none at
* all until it was added here.
*/
internal const val FUNDING_TYPE_ADDRESS_TOP_UP = 4
internal const val FUNDING_TYPE_SHIELDED_ADDRESS_TOP_UP = 5

/**
* Funding types the top-up recovery surface observes, in render order.
*
* This list is the whole point of `AssetLockDao.observeResumableTopUpsByFundingType`
* existing: the older `observeResumableAddressTopUps` hardcodes
* `fundingTypeRaw = 4`, so a stalled or `RecoveredFromChain` SHIELDED
* top-up (`5`) was returned by no query any screen ran. Widening the
* status predicate alone did not fix that — a row invisible on the
* funding-type axis stays invisible however generous the status axis is.
*/
internal val RESUMABLE_TOP_UP_FUNDING_TYPES = listOf(
FUNDING_TYPE_ADDRESS_TOP_UP,
FUNDING_TYPE_SHIELDED_ADDRESS_TOP_UP,
)

/**
* Cross-wallet, cross-funding-type stream of resumable orphan top-up locks,
* each tagged with the hex wallet id that owns it (needed for the Resume
* navigation, which is wallet-scoped while the Identities tab is not).
*
* [observe] is the DAO seam — production passes
* `AssetLockDao::observeResumableTopUpsByFundingType`, which applies the
* recoverable-status predicate (`[1,3] ∪ {5}`) SQL-side. Taking it as a
* parameter keeps this function a pure combinator: the funding-type fan-out
* that the blocker was about is then assertable without a Room database or
* a Compose runtime.
*
* Emissions are ordered by (wallet, funding type) so the rendered list
* doesn't reshuffle between recompositions.
*/
internal fun resumableTopUpsAcrossWallets(
walletIdHexes: List<String>,
observe: (walletId: ByteArray, fundingTypeRaw: Int) -> Flow<List<AssetLockEntity>>,
): Flow<List<Pair<String, AssetLockEntity>>> {
// `combine` over an empty source list never emits, which would leave the
// section stuck on its initial value instead of resolving to "nothing to
// recover". Short-circuit to an explicit empty emission.
if (walletIdHexes.isEmpty()) return flowOf(emptyList())

val slots: List<Pair<String, Int>> = walletIdHexes.flatMap { hex ->
RESUMABLE_TOP_UP_FUNDING_TYPES.map { fundingType -> hex to fundingType }
}
val flows = slots.map { (hex, fundingType) -> observe(hex.hexToBytes(), fundingType) }
return combine(flows) { emissions ->
slots.zip(emissions.toList()).flatMap { (slot, locks) ->
val (hex, _) = slot
locks.map { hex to it }
}
}
}

/**
* The screen a Resume tap on an orphan top-up row must open, or `null` when
* the row's funding type has no resume flow on this surface.
*
* Routing on `fundingTypeRaw` is the second half of the fix: surfacing a
* type-5 row is useless if its Resume lands on
* [org.dashfoundation.example.ui.funding.FundFromAssetLockScreen], whose
* submit calls `resumeFundFromAssetLock` — the platform-ADDRESS resume. A
* shielded lock has to reach `shieldedResumeFundFromAssetLock` instead,
* which is what [ShieldedFund] in resume mode does.
*
* Fail-closed on anything else: identity-family types (`0..3`) recover on
* the identity screens, and an unknown discriminant must not be dispatched
* to a resume flow that would mis-handle it.
*/
internal fun resumeRouteFor(walletIdHex: String, lock: AssetLockEntity): Any? =
when (lock.fundingTypeRaw) {
FUNDING_TYPE_ADDRESS_TOP_UP -> FundFromAssetLock(
walletIdHex = walletIdHex,
resumeOutPointHex = lock.outPointHex,
)
FUNDING_TYPE_SHIELDED_ADDRESS_TOP_UP -> ShieldedFund(
walletIdHex = walletIdHex,
resumeOutPointHex = lock.outPointHex,
)
else -> null
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity
import org.dashfoundation.example.di.LocalAppContainer
import org.dashfoundation.example.di.LocalAppState
import org.dashfoundation.example.navigation.CreateIdentity
Expand All @@ -49,8 +46,8 @@ import org.dashfoundation.example.navigation.StateTransitions
import org.dashfoundation.example.ui.components.EntityRow
import org.dashfoundation.example.ui.components.SectionHeader
import org.dashfoundation.example.ui.funding.PendingAssetLocksList
import org.dashfoundation.example.ui.funding.resumableTopUpsAcrossWallets
import org.dashfoundation.example.ui.wallet.toHexString
import org.dashfoundation.example.util.hexToBytes
import org.dashfoundation.example.util.toHex

/**
Expand Down Expand Up @@ -81,26 +78,24 @@ fun IdentitiesHomeScreen(navController: NavHostController) {
// DB-backed resumable orphan asset locks (ADDR-03), merged into the
// "Pending Platform Top Ups" surface below alongside the in-flight
// controllers. The Identities tab is network-scoped (not wallet-scoped),
// so we observe resumable address-topup locks across EVERY loaded wallet
// and tag each with its owning wallet id for the Resume navigation.
// so we observe resumable top-up locks across EVERY loaded wallet and tag
// each with its owning wallet id for the Resume navigation.
// ← the DB-backed orphan half of `PendingPlatformFundFromAssetLocksList.swift`.
//
// BOTH top-up funding types are observed (see
// `RESUMABLE_TOP_UP_FUNDING_TYPES`). This used to call
// `observeResumableAddressTopUps`, which pins `fundingTypeRaw = 4`, so a
// stalled or RecoveredFromChain SHIELDED top-up (`5`) was returned by no
// query this screen — or any other production surface — ran, and the
// funds it represents were unreachable from the UI entirely.
val loadedWallets by (manager?.wallets
?: MutableStateFlow(emptyMap())).collectAsStateWithLifecycle()
val walletIdHexes = loadedWallets.keys.sorted()
val resumableLocks by remember(walletIdHexes) {
if (walletIdHexes.isEmpty()) {
flowOf(emptyList<Pair<String, AssetLockEntity>>())
} else {
val flows = walletIdHexes.map { hex ->
val walletId = hex.hexToBytes()
container.database.assetLockDao().observeResumableAddressTopUps(walletId)
}
combine(flows) { arrays ->
walletIdHexes.zip(arrays.toList()).flatMap { (hex, locks) ->
locks.map { hex to it }
}
}
}
resumableTopUpsAcrossWallets(
walletIdHexes = walletIdHexes,
observe = container.database.assetLockDao()::observeResumableTopUpsByFundingType,
)
}.collectAsStateWithLifecycle(initialValue = emptyList())

Scaffold(
Expand Down
Loading
Loading