Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -62,6 +62,18 @@ class ShieldedFundFromAssetLockController(
var lastSubmittedAt: Long? = null
private set

/**
* Identity of the operation currently occupying this slot — the
* fresh-shield marker or the resumed lock's outpoint. Set by every
* accepted [submit]. The coordinator compares it to tell a re-tap of
* the SAME operation (reuse the controller) from a DIFFERENT
* operation on the same `(walletId, recipient)` slot, whose body
* [submit] would otherwise silently drop (two resumable locks
* normally share the wallet's default shielded recipient).
*/
var operationId: String? = null
private set

private var task: Job? = null

/** Composite id for stable list diffing — wallet hex + recipient hex. */
Expand All @@ -71,15 +83,17 @@ class ShieldedFundFromAssetLockController(
/**
* Submit the funding. Defensively rejects [Phase.InFlight] and
* [Phase.Completed]; [Phase.Idle] / [Phase.Failed] are allowed
* restarts. [body] performs the FFI shield call (returns nothing) or
* throws; it runs on its own dispatcher, the terminal flip hops back to
* [scope]. ← Swift `submit`.
* restarts. [operationId] names the operation the body performs (see
* [operationId]); [body] performs the FFI shield call (returns
* nothing) or throws; it runs on its own dispatcher, the terminal
* flip hops back to [scope]. ← Swift `submit`.
*/
fun submit(body: suspend () -> Unit) {
fun submit(operationId: String, body: suspend () -> Unit) {
when (_phase.value) {
is Phase.Idle, is Phase.Failed -> Unit
is Phase.InFlight, is Phase.Completed -> return
}
this.operationId = operationId
_phase.value = Phase.InFlight
lastSubmittedAt = now()
task = scope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ import kotlinx.coroutines.launch
* [StartFundingResult.BlockedByOtherWalletFunding] (mirroring the Rust-side
* `shield_guard` mutex — two concurrent Orchard builds on one wallet would
* race the note-commitment tree).
*
* Reuse of a controller additionally requires a matching **operation
* identity** (`operationId` — the resumed lock's outpoint, or a unique
* per-submission fresh-shield id): resumable locks normally default to
* the same wallet-owned shielded recipient, so two different locks share
* one slot key, and reusing the first lock's controller would silently
* drop the second lock's resume body. A different operation is blocked
* while the slot is in flight and replaces the retained controller once
* it has completed. Fresh shields mint a NEW id per submission for the
* same reason: a fixed marker would match the retained completed
* controller and suppress the next fresh shield's body.
*/
class ShieldedFundFromAssetLockCoordinator(
private val scope: CoroutineScope,
Expand Down Expand Up @@ -69,16 +80,31 @@ class ShieldedFundFromAssetLockCoordinator(
_controllers.value.values.sortedByDescending { it.lastSubmittedAt ?: Long.MIN_VALUE }

/**
* Start a funding for the slot. Reuses the existing controller if the
* SAME recipient is already in flight / just completed. Rejects a
* DIFFERENT recipient while another is in flight on the same wallet
* ([StartFundingResult.BlockedByOtherWalletFunding]). [Phase.Idle] /
* Start a funding for the slot. Reuses the existing controller only
* when the SAME operation ([operationId]) is already in flight / just
* completed on the slot — a re-tap. A DIFFERENT operation on an
* in-flight slot is reported as
* [StartFundingResult.BlockedByOtherWalletFunding] rather than
* silently reusing the controller (which would drop its body: two
* resumable locks normally share the wallet's default shielded
* recipient, so the slot key alone cannot tell them apart). A
* different operation on a *completed* slot is a fresh start — the
* retained controller is replaced. Rejects a DIFFERENT recipient
* while another is in flight on the same wallet. [Phase.Idle] /
* [Phase.Failed] on the same slot are legitimate restarts.
* ← Swift `startFunding`.
*
* [operationId] is the identity of the requested operation: the
* resumed lock's outpoint for a resume (stable, so a re-tap of the
* same lock rebinds), a unique per-submission id for a fresh shield
* (so a second fresh shield never rebinds to the previous one's
* retained result). Wallet-wide serialization is unchanged — at most
* one shield-class operation runs per wallet either way.
*/
fun startFunding(
walletId: ByteArray,
recipientRaw43: ByteArray,
operationId: String,
body: suspend () -> Unit,
): StartFundingResult {
val key = key(walletId, recipientRaw43)
Expand All @@ -94,22 +120,38 @@ class ShieldedFundFromAssetLockCoordinator(
}

if (existing != null) {
when (existing.phase.value) {
when (val phase = existing.phase.value) {
is ShieldedFundFromAssetLockController.Phase.InFlight,
is ShieldedFundFromAssetLockController.Phase.Completed,
-> return StartFundingResult.Started(existing)
-> {
if (existing.operationId == operationId) {
// Re-tap of the same operation: bind to its state.
return StartFundingResult.Started(existing)
}
if (phase is ShieldedFundFromAssetLockController.Phase.InFlight) {
// A different operation while one is running on this
// wallet: same serialization verdict as a different
// recipient — the caller gets the blocker, not a
// controller that will never run its body.
return StartFundingResult.BlockedByOtherWalletFunding(existing)
}
// Completed slot + different operation: a fresh start,
// not a re-tap. Fall through and REPLACE the retained
// controller (its sweep is identity-guarded, so the old
// retention timer can't evict the replacement).
}
is ShieldedFundFromAssetLockController.Phase.Idle,
is ShieldedFundFromAssetLockController.Phase.Failed,
-> {
existing.submit(body)
existing.submit(operationId, body)
scheduleRetentionSweep(key, existing)
return StartFundingResult.Started(existing)
}
}
}
val controller = ShieldedFundFromAssetLockController(walletId, recipientRaw43, scope, now)
_controllers.update { it + (key to controller) }
controller.submit(body)
controller.submit(operationId, body)
scheduleRetentionSweep(key, controller)
return StartFundingResult.Started(controller)
}
Expand All @@ -131,6 +173,12 @@ class ShieldedFundFromAssetLockCoordinator(
scope.launch {
var completedAt: Long? = null
while (true) {
// The slot may have been handed to a REPLACEMENT controller
// (a different operation started during this controller's
// completed-retention window). This sweep then owns nothing:
// exit without touching the slot, leaving the replacement's
// own sweep in charge.
if (_controllers.value[key] !== controller) return@launch
when (controller.phase.value) {
is ShieldedFundFromAssetLockController.Phase.Completed -> {
val nowMs = now()
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
Loading
Loading