Skip to content
Merged
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 @@ -1667,6 +1667,21 @@ class PlatformWalletPersistenceHandler(
updatedAt = now(),
),
)
// Spend-visibility reconcile: an asset-lock tx burns its value
// into the special-tx PAYLOAD and often has no wallet-owned
// standard output, so SPV block matching can miss it entirely —
// the spender's transaction row then never leaves mempool
// context and onWalletChangesetTransaction's in-block flip never
// runs, leaving the funding TXOs isSpent=0 (spendingTxid set)
// FOREVER. The lock's own STATUS is a signal that provably
// does arrive (the proof wait drives it): once it reaches
// InstantSendLocked (2) the network has locked the inputs, so
// flip the linked TXOs here. Monotonic, and keyed strictly to
// TXOs already linked to THIS lock's funding txid.
if (incomingStatus >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) {
val fundingTxid = outPoint.copyOfRange(0, 32)
db.txoDao().markSpentBySpendingTxid(fundingTxid, now())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
0
}
Expand Down Expand Up @@ -2414,6 +2429,36 @@ class PlatformWalletPersistenceHandler(
return out.toTypedArray()
}

/**
* Whether the transaction [spendingTxid] funds an asset lock the
* network has already locked (`InstantSendLocked` or beyond), or
* `null` when the asset-lock table could not be read.
*
* Keyed on the funding TXID alone, never on a single outpoint:
* DIP-0027 lets one funding transaction carry several credit
* outputs, and Rust persists each tracked lock under its own
* credit-output index, so the lock a given spend produced can sit at
* any vout. Finality belongs to the transaction, so any of its locks
* reaching InstantSendLocked means the inputs are gone.
*
* `null` is a deliberate third answer, not a swallowed error. This
* runs inside `guardedLoad(emptyArray())` and the Android load
* surface carries no error channel, so an escaping read failure would
* hand Rust a SUCCESSFUL EMPTY restore for every wallet — the
* strongest possible "this device has no coins". The fault is
* therefore contained to the single candidate it concerns and every
* unrelated wallet, account and TXO still restores.
*/
private suspend fun spendByFinalizedAssetLock(spendingTxid: ByteArray): Boolean? =
try {
val status = database.assetLockDao()
.maxStatusForTxid(spendingTxid.reversedArray().toHex())
status != null && status >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED
} catch (t: Throwable) {
Log.w(TAG, "load: asset-lock finality lookup failed; dropping the candidate UTXO", t)
null
}

/**
* Assemble the [UtxoRestoreData] rows for one wallet: every unspent
* `txos` row, routed to its owning account for the leading
Expand Down Expand Up @@ -2459,6 +2504,40 @@ class PlatformWalletPersistenceHandler(
if (spendingTxid != null) {
val spending = database.transactionDao().getByTxid(spendingTxid)
if (spending != null && spending.context >= CONTEXT_IN_BLOCK) continue
// Asset-lock spender: the lock tx burns its value into the
// special-tx payload and often has no wallet-owned standard
// output, so SPV block matching can miss it and its row sits
// at mempool context FOREVER — the guard above never fires,
// and every relaunch resurrects the consumed output into the
// engine's balance. The tracked lock's own status is the
// finality signal that provably arrives; from
// InstantSendLocked on this output is gone. Skip it, and
// heal the flag so isSpent-based readers stop counting it.
when (spendByFinalizedAssetLock(spendingTxid)) {
// Provably final. Heal opportunistically: excluding
// the row from THIS restore does not depend on the
// repair becoming durable, and the whole body of
// `onLoadWalletList` runs under
// `guardedLoad(emptyArray())` — an escaping write
// failure would discard every wallet's restore set
// over one unhealed row. Log and carry on instead,
// the way `scrubAliases` treats its cleanup.
true -> {
try {
database.txoDao().markSpentByOutpoint(txo.outpoint, now())
} catch (t: Throwable) {
Log.w(TAG, "load: failed to heal asset-lock-consumed TXO", t)
}
continue
}
// Unreadable (see the helper): drop this one candidate
// and never heal it. Under-reporting one output for a
// launch is recoverable; handing a consumed output back
// as spendable is what this guard exists to stop.
null -> continue
// Demonstrably not final — keep it in the restore set.
false -> Unit
}
}
val account = txo.accountId?.let { database.accountDao().getById(it) }
?: accountByAddress.getOrPut(txo.address) {
Expand Down Expand Up @@ -3284,6 +3363,17 @@ class PlatformWalletPersistenceHandler(
/** `TransactionContext::InBlock` — spends only count once in-block. */
private const val CONTEXT_IN_BLOCK = 2

/**
* Rust `AssetLockStatus` wire bytes
* (`wallet::asset_lock::tracked`): Built 0, Broadcast 1,
* InstantSendLocked 2, ChainLocked 3, Consumed 4,
* RecoveredFromChain 5. At InstantSendLocked the network has
* locked the funding inputs, and every status above it is a
* strictly stronger finality claim — so the spend-visibility
* reconcile treats the linked TXOs as spent from there on.
*/
private const val ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED = 2

/** `Network.testnet` rawValue — the Swift fallback network. */
private const val NETWORK_TESTNET = 1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,34 @@ interface AssetLockDao {
@Query("SELECT * FROM asset_locks WHERE outPointHex = :outPointHex")
suspend fun getByOutPointHex(outPointHex: String): AssetLockEntity?

/**
* Strongest lifecycle status any asset lock funded by [txidHex] has
* reached, or null when the transaction funds no tracked lock.
*
* [txidHex] is the explorer DISPLAY txid hex (64 chars, wire order
* reversed) — the prefix of the `outPointHex` PK
* (`<txidDisplayHex>:<vout>`). Deliberately keyed on the txid alone
* and NOT on a whole outpoint: DIP-0027 lets one funding transaction
* carry several credit outputs, and `sync/reconstruction.rs` persists
* each of them under its own credit-output index, so the lock a given
* funding transaction produced can live at any vout. Finality is a
* property of the transaction, so `MAX` over the whole prefix is the
* right reduction — any output of it reaching InstantSendLocked means
* the transaction's inputs are gone.
*
* Same 64-hex input contract as [fundingTypeForTxid], enforced in SQL
* and compared against the exact 65-char `<txid>:` prefix rather than
* a LIKE pattern, so `%`/`_` in malformed input can never match
* arbitrary rows.
*/
@Query(
"SELECT MAX(statusRaw) FROM asset_locks " +
"WHERE length(:txidHex) = 64 " +
"AND lower(:txidHex) NOT GLOB '*[^0-9a-f]*' " +
"AND substr(outPointHex, 1, 65) = lower(:txidHex) || ':'"
)
suspend fun maxStatusForTxid(txidHex: String): Int?

/**
* Transaction-label resolver probe: the `fundingTypeRaw` of the asset
* lock whose outpoint belongs to [txidHex]. [txidHex] is the explorer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.dashfoundation.dashsdk.persistence.entities.TxoEntity
import java.util.Date

/**
* Queries over [TxoEntity], mirroring the Swift call sites:
Expand Down Expand Up @@ -43,6 +44,31 @@ interface TxoDao {
@Query("SELECT * FROM txos WHERE spendingTxid = :spendingTxid AND isSpent = 0")
suspend fun getUnspentBySpendingTxid(spendingTxid: ByteArray): List<TxoEntity>

/**
* Flip `isSpent` on every still-unspent TXO consumed by
* [spendingTxid] — the heal a finalized asset lock drives when SPV
* block matching missed its spender and the ordinary in-block flip
* never ran (see `onPersistAssetLockUpsert`).
*
* Column-scoped and conditioned on `isSpent = 0`: it cannot regress
* an already-spent row, and unlike a read-then-[upsert] round trip it
* never writes back a stale copy of the columns it does not own.
* Promote-only and idempotent — a second run matches no rows. Returns
* the number of rows healed.
*/
@Query(
"UPDATE txos SET isSpent = 1, lastUpdated = :now " +
"WHERE spendingTxid = :spendingTxid AND isSpent = 0",
)
suspend fun markSpentBySpendingTxid(spendingTxid: ByteArray, now: Date): Int

/** Single-row [markSpentBySpendingTxid], keyed by the TXO's own outpoint. */
@Query(
"UPDATE txos SET isSpent = 1, lastUpdated = :now " +
"WHERE outpoint = :outpoint AND isSpent = 0",
)
suspend fun markSpentByOutpoint(outpoint: ByteArray, now: Date): Int

@Upsert
suspend fun upsert(txo: TxoEntity)

Expand Down
Loading
Loading