Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/sdk/sdk-parity-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@
"host": "kotlin",
"kind": "unit",
"file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogicTest.kt",
"id": "consensusConsumedWithMarkerIsExplicitlyAmbiguousNeverReclaimed",
"id": "consensusConsumedWithMarkerIsUnknown",
"command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*InvitationReclaimLogicTest*'",
"covers_restart": false
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,13 @@ import org.dashfoundation.dashsdk.errors.DashSdkError
*/
object InvitationReclaimLogic {

/** The terminal state a reclaim attempt resolves to. */
/** The state a failed reclaim attempt resolves to. */
enum class ReclaimOutcome {
/**
* This wallet retained a tombstone written after its successful
* local consume, so the interrupted reclaim recovers definitively.
* The lock was reported as consumed, but neither consumption nor this
* reclaim's completion is authenticated.
*/
RECLAIMED,

/** The voucher was consumed with no local attempt in flight — a foreign claim. */
CLAIMED,

/**
* Provably consumed (deterministic Platform rejection), but our own
* in-flight attempt makes the consumer ambiguous. Resolves to the
* conservative terminal Claimed, never an inferred Reclaimed.
*/
CONSUMED_AMBIGUOUS,
CONSUMPTION_UNKNOWN,

/**
* The wallet no longer tracks the voucher lock and our own attempt
Expand All @@ -42,25 +32,15 @@ object InvitationReclaimLogic {

/**
* Pure decision for the reclaim failure path. A typed wallet
* [DashSdkError.PlatformWallet.AssetLockAlreadyConsumed] comes from a
* retained local tombstone written only after this wallet's successful
* consume, and therefore recovers Reclaimed. Consensus wording is only
* proof the lock is consumed — not who consumed it — so the prior
* in-flight marker splits that fallback into a foreign claim vs an
* explicitly ambiguous consumption.
* [DashSdkError.PlatformWallet.AssetLockAlreadyConsumed] and the legacy
* consensus wording are both consumption-unknown signals. Code 24 cannot
* distinguish a local tombstone from an unauthenticated remote report.
*/
fun classifyReclaimFailure(
error: Throwable,
hadPriorReclaimInFlight: Boolean,
): ReclaimOutcome {
if (isLocallyConsumedTombstone(error)) return ReclaimOutcome.RECLAIMED
if (isAlreadyConsumed(error.message.orEmpty())) {
return if (hadPriorReclaimInFlight) {
ReclaimOutcome.CONSUMED_AMBIGUOUS
} else {
ReclaimOutcome.CLAIMED
}
}
if (isAlreadyConsumed(error)) return ReclaimOutcome.CONSUMPTION_UNKNOWN
// A retry after our own crash-interrupted consume can also fail
// LOCALLY ("…is not tracked") — before Platform. Consistent with the
// consume having landed but not proof of it, so it gets its own
Expand All @@ -79,34 +59,28 @@ object InvitationReclaimLogic {
* proof the consume never started, so the freshly-set marker is stale
* (leaving it would degrade an identical retry into the two-attempt
* false-ambiguity outcome). Every other error keeps the marker so a later
* "already consumed" stays classified as ambiguous.
* "already consumed" retains its consumption-unknown recovery state.
*/
fun shouldClearInFlightMarker(
error: Throwable,
hadPriorReclaimInFlight: Boolean,
): Boolean = !hadPriorReclaimInFlight && isLockNoLongerTracked(error.message.orEmpty())

/**
* The wallet's typed local consumed tombstone — written only after this
* wallet's own successful consume, so it can safely recover Reclaimed.
*/
fun isLocallyConsumedTombstone(error: Throwable): Boolean =
error is DashSdkError.PlatformWallet.AssetLockAlreadyConsumed

/**
* The deterministic consensus 10504 rejection. Matched on the exact
* Legacy consensus-10504 compatibility fallback. Matched on the exact
* canonical Display phrase of
* `IdentityAssetLockTransactionOutPointAlreadyConsumedError` ONLY —
* broader phrases would widen false-positive risk (misclassifying an
* unrelated failure as a benign "already claimed" wrongly flips the row
* to Claimed). The typed tombstone above is the primary signal; this
* wording is the compatibility fallback for errors originating below the
* typed wallet boundary. (A typed FFI code for 10504 is a known
* follow-up shared with iOS.)
* broader phrases would widen false-positive risk. The report is not
* authenticated and never proves the requested operation completed. The
* typed code is the primary signal; wording remains for older boundaries.
*/
fun isAlreadyConsumed(message: String): Boolean =
message.lowercase().contains("already completely used")

fun isAlreadyConsumed(error: Throwable): Boolean =
error is DashSdkError.PlatformWallet.AssetLockAlreadyConsumed ||
isAlreadyConsumed(error.message.orEmpty())

/**
* The wallet's LOCAL "asset lock … is not tracked" resume-guard failure
* — distinct from the network's already-consumed rejection; used only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ import org.dashfoundation.example.util.toHex
* - In-memory [isReclaiming] single-flights submit AND dismissal — the
* persisted `reclaimInFlight` marker is crash forensics, never the
* concurrency guard (a Room-Flow re-emit recomposes this sheet; an
* unguarded second consume would let the loser's classifier overwrite
* Reclaimed with Claimed).
* unguarded second consume could overwrite the first attempt's state).
* - The marker is persisted (and the write MUST succeed) only immediately
* before the on-chain consume; the register arm's key pre-persist runs
* BEFORE the marker so a purely local failure never strands one.
Expand Down Expand Up @@ -118,8 +117,8 @@ fun ReclaimInvitationSheet(
// The write must SUCCEED before the consume may run (an
// unpersisted marker + consume + crash would strand the
// row). The persisted prior value is captured first: it is
// what downgrades a later "already consumed" from
// "provably a foreign claim" to "explicitly ambiguous".
// retained as crash evidence when a later submission
// reports consumption but cannot prove the operation.
suspend fun markInFlight() {
hadPriorReclaimInFlight =
dao.getByOutPointHex(hex)?.reclaimInFlight ?: false
Expand Down Expand Up @@ -191,26 +190,13 @@ fun ReclaimInvitationSheet(
onClose()
} catch (t: Throwable) {
when (InvitationReclaimLogic.classifyReclaimFailure(t, hadPriorReclaimInFlight)) {
ReclaimOutcome.RECLAIMED -> {
dao.setStatusAndMarker(hex, 2, false, System.currentTimeMillis())
infoMessage = "This invitation was already reclaimed by this " +
"wallet. The credits were delivered to the target selected " +
"for that reclaim."
}
ReclaimOutcome.CLAIMED -> {
// Neutral copy — the claimant is intentionally not named.
dao.setStatusAndMarker(hex, 1, false, System.currentTimeMillis())
infoMessage = "This invitation was already claimed."
}
ReclaimOutcome.CONSUMED_AMBIGUOUS -> {
// Provably consumed, but attribution is unknowable with
// our own attempt in flight — conservative terminal
// Claimed, never an inferred Reclaimed.
dao.setStatusAndMarker(hex, 1, false, System.currentTimeMillis())
infoMessage = "This invitation was already consumed — by the " +
"invitee's claim, or possibly by your own earlier " +
"interrupted reclaim. If that reclaim went through, the " +
"credits were delivered to the target you selected then."
ReclaimOutcome.CONSUMPTION_UNKNOWN -> {
// Code 24 can mean either a retained local tombstone or
// an unauthenticated Platform report. Retain status and
// the in-flight marker because completion is unknown.
errorMessage = "This asset lock was reported as already used, but " +
"the wallet could not verify whether this reclaim completed. " +
"Sync and check the selected target before retrying."
}
ReclaimOutcome.UNTRACKED_AFTER_OWN_ATTEMPT -> {
// No on-chain proof of consumption at all — status and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,31 +30,31 @@ class InvitationReclaimLogicTest {
// ── Outcome matrix ────────────────────────────────────────────────

@Test
fun typedTombstoneRecoversReclaimedRegardlessOfMarker() {
fun typedAlreadyConsumedIsUnknownRegardlessOfMarker() {
assertEquals(
ReclaimOutcome.RECLAIMED,
ReclaimOutcome.CONSUMPTION_UNKNOWN,
InvitationReclaimLogic.classifyReclaimFailure(typedTombstone(), false),
)
assertEquals(
ReclaimOutcome.RECLAIMED,
ReclaimOutcome.CONSUMPTION_UNKNOWN,
InvitationReclaimLogic.classifyReclaimFailure(typedTombstone(), true),
)
}

@Test
fun consensusConsumedWithoutMarkerIsAForeignClaim() {
fun consensusConsumedWithoutMarkerIsUnknown() {
assertEquals(
ReclaimOutcome.CLAIMED,
ReclaimOutcome.CONSUMPTION_UNKNOWN,
InvitationReclaimLogic.classifyReclaimFailure(
RuntimeException(consumedMessage), hadPriorReclaimInFlight = false,
),
)
}

@Test
fun consensusConsumedWithMarkerIsExplicitlyAmbiguousNeverReclaimed() {
fun consensusConsumedWithMarkerIsUnknown() {
assertEquals(
ReclaimOutcome.CONSUMED_AMBIGUOUS,
ReclaimOutcome.CONSUMPTION_UNKNOWN,
InvitationReclaimLogic.classifyReclaimFailure(
RuntimeException(consumedMessage), hadPriorReclaimInFlight = true,
),
Expand Down Expand Up @@ -123,8 +123,8 @@ class InvitationReclaimLogicTest {
fun alreadyConsumedMatchesTheExactCanonicalPhraseOnly() {
assertTrue(InvitationReclaimLogic.isAlreadyConsumed(consumedMessage))
assertTrue(InvitationReclaimLogic.isAlreadyConsumed(consumedMessage.uppercase()))
// Broader wordings must NOT match — a false positive would wrongly
// flip the row to Claimed.
// Broader wordings must NOT match — a false positive would hide an
// unrelated failure behind consumption-unknown recovery guidance.
assertFalse(InvitationReclaimLogic.isAlreadyConsumed("asset lock already consumed"))
assertFalse(InvitationReclaimLogic.isAlreadyConsumed("output already used"))
assertFalse(InvitationReclaimLogic.isAlreadyConsumed("completely unrelated"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ sealed class DashSdkError(
class AssetLockNotTracked(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* The one-shot output cannot be reused. This may be a retained local
* tombstone or an unauthenticated Platform report; operation completion
* must not be inferred from this signal.
*/
class AssetLockAlreadyConsumed(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ class PlatformWalletPersistenceHandler(
CAPABILITY_PROVIDER_TRANSACTIONS or
CAPABILITY_UNSIGNED_TOKEN_STORAGE or
CAPABILITY_WALLET_RESTORE or
CAPABILITY_DPNS_NAME_STATES
CAPABILITY_DPNS_NAME_STATES or
CAPABILITY_TRACKED_ASSET_LOCKS

/**
* The single-thread executor created when no [dispatcher] is injected.
Expand Down Expand Up @@ -3176,6 +3177,7 @@ class PlatformWalletPersistenceHandler(
internal const val CAPABILITY_UNSIGNED_TOKEN_STORAGE: Long = 0x20
internal const val CAPABILITY_WALLET_RESTORE: Long = 0x80
internal const val CAPABILITY_DPNS_NAME_STATES: Long = 0x100
internal const val CAPABILITY_TRACKED_ASSET_LOCKS: Long = 0x200

private const val TAG = "DashPersistence"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ data class PlatformWalletPersistenceCapabilities(
const val PENDING_CONTACT_CRYPTO: Long = 1L shl 6
const val WALLET_RESTORE: Long = 1L shl 7
const val DPNS_NAME_STATES: Long = 1L shl 8
const val TRACKED_ASSET_LOCKS: Long = 1L shl 9
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class PlatformWalletPersistenceHandlerTest {
assertEquals(0L, noOpBridge.persistenceCapabilitiesBits())

assertEquals(1, handler.persistenceCapabilitiesVersion())
assertEquals(0x1bfL, handler.persistenceCapabilitiesBits())
assertEquals(0x3bfL, handler.persistenceCapabilitiesBits())
// Android has no pending-contact-crypto callback, so it must not
// attest that semantic contract.
assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L)
Expand All @@ -77,6 +77,7 @@ class PlatformWalletPersistenceHandlerTest {
assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.ATOMIC_CHANGESETS))
assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.INVITATIONS))
assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES))
assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS))
}

// ── Standalone (non-bracketed) writes ─────────────────────────────
Expand Down
4 changes: 2 additions & 2 deletions packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ pub struct TrackedAssetLockFFI {
/// Amount in duffs.
pub amount: u64,
/// Status (0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked,
/// 4=Consumed, 5=RecoveredFromChain — finality proven by the restore
/// scan, Platform-side consumption unknown).
/// 4=Consumed, 5=RecoveredFromChain — Core finality proven by restore
/// reconstruction or live reconciliation, Platform-side consumption unknown).
pub status: u32,
/// Whether a proof is attached.
pub has_proof: bool,
Expand Down
3 changes: 2 additions & 1 deletion packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ pub enum PlatformWalletFFIResultCode {
ErrorCoreInsufficientFunds = 22,
/// Existing-lock recovery referenced an outpoint not owned/tracked by the wallet.
ErrorAssetLockNotTracked = 23,
/// Existing-lock recovery referenced a one-shot output already consumed.
/// Asset-lock funding cannot reuse this one-shot output; Platform
/// completion for the requested operation is unconfirmed.
ErrorAssetLockAlreadyConsumed = 24,
/// Existing-lock recovery attempted to use a lock for the wrong funding
/// family or bound identity index.
Expand Down
Loading
Loading