Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -59,18 +59,54 @@ interface AssetLockDao {

/**
* Resumable Platform-address top-up locks — `fundingTypeRaw == 4`
* (AssetLockAddressTopUp) and `statusRaw ∈ [1, 3]` (Broadcast through
* ChainLocked, excluding Built and Consumed). Backs the "Pending
* Platform Top Ups" orphan surface (← the SwiftData `@Query` behind
* `PendingPlatformFundFromAssetLocksList.swift`, whose Swift filter is
* `fundingTypeRaw == 4 && isVisibleAsResumable`).
* (AssetLockAddressTopUp) and a recoverable, non-terminal status.
* Backs the "Pending Platform Top Ups" orphan surface (← the SwiftData
* `@Query` behind `PendingPlatformFundFromAssetLocksList.swift`, whose
* Swift filter is `fundingTypeRaw == 4 && isVisibleAsResumable`).
*
* The recoverable set is `[1, 3] ∪ {5}` — Broadcast, InstantSendLocked,
* ChainLocked, and RecoveredFromChain. `0` (Built) is excluded because
* the funding transaction has not been broadcast; `4` (Consumed) is the
* terminal tombstone that Rust's `resume_asset_lock` rejects outright.
*
* `5` (RecoveredFromChain) is not a gap in the ordering — it is a
* distinct status written by the restore scan and by the chainlock
* promotion path for a lock whose Core finality is proven but whose
* Platform-side consumption is unknown (`sync/reconstruction.rs`). A
* range bounded at `3` dropped exactly those rows, so a chain-locked
* address top-up rebuilt from history appeared on no surface at all.
*/
@Query(
"SELECT * FROM asset_locks WHERE walletId = :walletId " +
"AND fundingTypeRaw = 4 AND statusRaw >= 1 AND statusRaw <= 3"
"AND fundingTypeRaw = 4 " +
"AND ((statusRaw >= 1 AND statusRaw <= 3) OR statusRaw = 5)"
)
fun observeResumableAddressTopUps(walletId: ByteArray): Flow<List<AssetLockEntity>>

/**
* Funding-type-scoped variant of [observeResumableAddressTopUps], using
* the identical recoverable-status predicate.
*
* Exists because shielded address top-ups (`fundingTypeRaw == 5`,
* `AssetLockShieldedAddressTopUp`) had no resumable query at all: the
* query above is pinned to `4`, and the identity-recovery surface fed by
* `TrackedAssetLock.eligibleFromNative` deliberately admits only funding
* types `0..2`. A stalled or chain-locked shielded top-up was therefore
* invisible on every host surface.
*
* Pass `4` (address) or `5` (shielded). Funding types `0..3` are
* identity-family locks, whose recovery surface is the identity screens.
*/
@Query(
"SELECT * FROM asset_locks WHERE walletId = :walletId " +
"AND fundingTypeRaw = :fundingTypeRaw " +
"AND ((statusRaw >= 1 AND statusRaw <= 3) OR statusRaw = 5)"
)
fun observeResumableTopUpsByFundingType(
walletId: ByteArray,
fundingTypeRaw: Int,
): Flow<List<AssetLockEntity>>
Comment thread
bfoss765 marked this conversation as resolved.

@Query("SELECT * FROM asset_locks WHERE outPointHex = :outPointHex")
suspend fun getByOutPointHex(outPointHex: String): AssetLockEntity?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ data class AssetLockEntity(
val amountDuffs: Long,
/**
* `AssetLockStatus` discriminant: 0 Built, 1 Broadcast,
* 2 InstantSendLocked, 3 ChainLocked, 4 Consumed.
* 2 InstantSendLocked, 3 ChainLocked, 4 Consumed,
* 5 RecoveredFromChain.
*
* `4` is terminal; `5` is NOT, its higher discriminant
* notwithstanding. `5` means Core finality is proven while
* Platform-side consumption is unknown — what the restore scan and the
* chainlock-promotion path write — so it belongs in every "still
* recoverable" predicate alongside `1..3`, and a contiguous `1..3`
* range silently drops it.
*/
val statusRaw: Int,
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package org.dashfoundation.dashsdk.persistence

import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

/**
* In-memory Room contract tests for the resumable asset-lock predicates on
* [org.dashfoundation.dashsdk.persistence.dao.AssetLockDao].
*
* The behavior under test is the status domain. Rust's `AssetLockStatus`
* is `0 Built, 1 Broadcast, 2 InstantSendLocked, 3 ChainLocked,
* 4 Consumed, 5 RecoveredFromChain`, and the recoverable set is NOT a
* contiguous range: `4` is the terminal tombstone that must stay hidden,
* while `5` — written by the restore scan and the chainlock-promotion path
* for a lock with proven Core finality and unknown Platform-side
* consumption — must be visible.
*
* Expressing that as `statusRaw >= 1 AND statusRaw <= 3` dropped every
* recovered row, so a chain-locked top-up the user really funded appeared
* on no host surface at all. These tests pin both ends: `5` in, `4` out.
*/
@RunWith(RobolectricTestRunner::class)
class AssetLockResumableDaoTest {

private lateinit var db: DashDatabase

private val walletId = ByteArray(32) { 1 }
private val otherWalletId = ByteArray(32) { 2 }

/** `AssetLockFundingType` discriminants. */
private val fundingAddressTopUp = 4
private val fundingShieldedTopUp = 5
private val fundingIdentityRegistration = 0

@Before
fun setUp() {
db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext())
}

@After
fun tearDown() {
db.close()
}

private suspend fun insert(
outPointHex: String,
statusRaw: Int,
fundingTypeRaw: Int = fundingAddressTopUp,
owner: ByteArray = walletId,
) {
db.assetLockDao().upsert(
AssetLockEntity(
outPointHex = outPointHex,
walletId = owner,
transactionBytes = ByteArray(4),
fundingTypeRaw = fundingTypeRaw,
identityIndexRaw = 0,
amountDuffs = 10_000,
statusRaw = statusRaw,
),
)
}

private suspend fun resumableAddressOutpoints(): List<String> =
db.assetLockDao().observeResumableAddressTopUps(walletId).first()
.map { it.outPointHex }
.sorted()

// ── observeResumableAddressTopUps ─────────────────────────────────

/**
* The regression itself: status 5 must be returned. Before the fix the
* upper bound of `3` hid it, and the funds it represents were
* unreachable from the UI.
*/
@Test
fun resumableAddressTopUpsIncludeRecoveredFromChain() = runTest {
insert("aa:0", statusRaw = 5)

assertEquals(listOf("aa:0"), resumableAddressOutpoints())
}

/** Every recoverable status, and only those. */
@Test
fun resumableAddressTopUpsCoverTheWholeRecoverableDomain() = runTest {
insert("built:0", statusRaw = 0)
insert("broadcast:0", statusRaw = 1)
insert("islocked:0", statusRaw = 2)
insert("chainlocked:0", statusRaw = 3)
insert("consumed:0", statusRaw = 4)
insert("recovered:0", statusRaw = 5)

assertEquals(
listOf("broadcast:0", "chainlocked:0", "islocked:0", "recovered:0"),
resumableAddressOutpoints(),
)
}

/**
* The terminal guard from #4347 must survive the widening. A Consumed
* row that re-surfaced would be a perpetual-spinner the underlying
* `resume_asset_lock` rejects with "already Consumed — nothing to
* resume".
*/
@Test
fun resumableAddressTopUpsStillExcludeConsumed() = runTest {
insert("consumed:0", statusRaw = 4)

assertEquals(emptyList<String>(), resumableAddressOutpoints())
}

/** Built (0) stays out: nothing has been broadcast yet. */
@Test
fun resumableAddressTopUpsStillExcludeBuilt() = runTest {
insert("built:0", statusRaw = 0)

assertEquals(emptyList<String>(), resumableAddressOutpoints())
}

/** The funding-type and wallet scoping are unchanged by the widening. */
@Test
fun resumableAddressTopUpsStayScopedToFundingTypeFourAndWallet() = runTest {
insert("identity:0", statusRaw = 5, fundingTypeRaw = fundingIdentityRegistration)
insert("shielded:0", statusRaw = 5, fundingTypeRaw = fundingShieldedTopUp)
insert("foreign:0", statusRaw = 5, owner = otherWalletId)
insert("mine:0", statusRaw = 5)

assertEquals(listOf("mine:0"), resumableAddressOutpoints())
}

// ── observeResumableTopUpsByFundingType ───────────────────────────

/**
* Shielded address top-ups (funding type 5) previously had no resumable
* query at all — the address query is pinned to 4, and the
* identity-recovery surface (`TrackedAssetLock.eligibleFromNative`)
* deliberately admits only funding types 0..2 — so a stalled shielded
* top-up was invisible everywhere.
*/
@Test
fun resumableByFundingTypeCoversShieldedTopUps() = runTest {
insert("shielded-broadcast:0", statusRaw = 1, fundingTypeRaw = fundingShieldedTopUp)
insert("shielded-recovered:0", statusRaw = 5, fundingTypeRaw = fundingShieldedTopUp)
insert("shielded-consumed:0", statusRaw = 4, fundingTypeRaw = fundingShieldedTopUp)
insert("shielded-built:0", statusRaw = 0, fundingTypeRaw = fundingShieldedTopUp)
insert("address-recovered:0", statusRaw = 5, fundingTypeRaw = fundingAddressTopUp)

val shielded = db.assetLockDao()
.observeResumableTopUpsByFundingType(walletId, fundingShieldedTopUp)
.first()
.map { it.outPointHex }
.sorted()

assertEquals(listOf("shielded-broadcast:0", "shielded-recovered:0"), shielded)
}

/** Parameterized with 4, it agrees with the dedicated address query. */
@Test
fun resumableByFundingTypeAgreesWithTheAddressQuery() = runTest {
insert("a:0", statusRaw = 1)
insert("b:0", statusRaw = 5)
insert("c:0", statusRaw = 4)
insert("d:0", statusRaw = 0)

val parameterized = db.assetLockDao()
.observeResumableTopUpsByFundingType(walletId, fundingAddressTopUp)
.first()
.map { it.outPointHex }
.sorted()

assertEquals(resumableAddressOutpoints(), parameterized)
assertEquals(listOf("a:0", "b:0"), parameterized)
}
}
Loading
Loading