Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
56932d4
fix(platform-wallet): carry chain truncations to the persister
lklimek Oct 12, 2026
fce09b1
fix(platform-wallet): return a sent payment to pending when its block…
lklimek Oct 12, 2026
3da5fc2
fix(platform-wallet): fence coins a chain truncation hands back
lklimek Oct 12, 2026
e6c0f9d
fix(platform-wallet): apply chain truncations in the SQLite store
lklimek Oct 12, 2026
e9d890e
fix(platform-wallet): declare CORE_CHAIN_TRUNCATION on bit 14
lklimek Oct 12, 2026
d907f36
feat(platform-wallet): deliver chain truncations to FFI hosts
lklimek Oct 12, 2026
d2147e2
feat(sdk): bridge chain truncations to the Kotlin persistence bridge
lklimek Oct 12, 2026
c9129f3
fix(swift-sdk): apply chain truncations in the SwiftData store
lklimek Oct 12, 2026
fce95af
fix(kotlin-sdk): apply chain truncations in the Room store
lklimek Oct 12, 2026
e38368b
test(platform-wallet-storage): pin the InstantSend lock set across a …
lklimek Oct 12, 2026
05e343b
docs(platform-wallet): state the InstantSend context in the truncatio…
lklimek Oct 12, 2026
af930d9
fix(swift-sdk): store a transaction a truncation keeps as InstantSend…
lklimek Oct 12, 2026
1367322
fix(kotlin-sdk): store a transaction a truncation keeps as InstantSen…
lklimek Oct 12, 2026
34a16e2
style(platform-wallet-storage): format the truncation tests
lklimek Oct 12, 2026
3a1aa90
chore: merge fix/pr-5126 into fix/wallet-persist-chain-truncation
lklimek Oct 12, 2026
29b65d7
fix(platform-wallet-storage): keep a truncation when a failed buffer …
lklimek Oct 12, 2026
ecf5e11
fix(platform-wallet): project a transaction a merged truncation keeps…
lklimek Oct 12, 2026
041da26
fix(swift-sdk): take only the truncating wallet's part of a shared tr…
lklimek Oct 12, 2026
44ed537
fix(kotlin-sdk): take only the truncating wallet's part of a shared t…
lklimek Oct 12, 2026
8b63a1e
test(platform-wallet-storage): pin the safe side of a merged sweep an…
lklimek Oct 12, 2026
d582cff
fix(swift-sdk): read an involved account's wallet through an Optional…
lklimek Oct 12, 2026
5889000
fix(platform-wallet-storage): keep each owed truncation once
lklimek Oct 12, 2026
d1a4c0f
fix(platform-wallet): offer a payment a rejected truncation returned …
lklimek Oct 12, 2026
3d6aeca
docs(platform-wallet): state why a truncation is retried after an unv…
lklimek Oct 12, 2026
22d050f
fix(platform-wallet): fence a restored coin where the wallet is trunc…
lklimek Oct 12, 2026
e9b2aae
fix(platform-wallet): give up a carried payment reversal the store re…
lklimek Oct 12, 2026
a20fdff
fix(platform-wallet): keep a round's own payment reversal when a carr…
lklimek Oct 13, 2026
307a662
Merge fix/pr-5126 (7d192bfa88) into fix/wallet-persist-chain-truncation
lklimek Oct 13, 2026
0a5d5f3
Merge fix/pr-5126 (39a6de06d6) into fix/wallet-persist-chain-truncation
lklimek Oct 13, 2026
30bd704
chore(platform-wallet): refresh reorg persistence on updated wallet base
lklimek Oct 21, 2026
cfa14d0
chore(platform-wallet): propagate reviewed SQLite capability fix
lklimek Oct 21, 2026
31b0df1
Merge branch 'fix/pr-5126' into fix/wallet-persist-chain-truncation
lklimek Oct 27, 2026
7286da4
refactor(platform-wallet): let the changeset decide a kept record's l…
lklimek Oct 28, 2026
dd0accc
test(kotlin-sdk): expect the chain-truncation capability at bit 15
lklimek Oct 29, 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
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,18 @@
[rust-dashcore#1183](https://github.com/dashpay/rust-dashcore/pull/1183)
runs the storage writer only between start and stop.
- **platform-wallet:** a chain reorganization reported by key-wallet
(`WalletEvent::ChainTruncated`) updates the in-memory balance but is not yet
mirrored to persisted wallet state
(`WalletEvent::ChainTruncated`) now reaches persisted wallet state as
`CoreChangeSet::truncations`. The SQLite backend and the Swift and Kotlin
Comment thread
lklimek marked this conversation as resolved.
persistence handlers remove the transactions of the dropped blocks and their
coins, keep InstantSend-locked ones as unconfirmed, restore the coins the
removed ones spent and lower the stored sync heights to the fork, so a
restart after a reorg loads the same wallet as no restart. FFI hosts receive
truncations through the size-negotiated
`on_persist_wallet_changeset_truncations_fn` extension callback, Kotlin
bridges through `onWalletChangesetChainTruncated`. A persister attests
support with the new
`PersistenceCapabilities::CORE_CHAIN_TRUNCATION` bit; one that does not is
not handed the truncation and keeps the replaced branch until it rescans
([#5380](https://github.com/dashpay/platform/issues/5380)).
- **platform-wallet:** the SPV broadcast-readiness wait (`SpvRuntime::wait_until_ready`)
wakes when the client starts or dash-spv reports a peer update, instead of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ abstract class NativePersistenceBridge {
* it, so the declaration and the mirror can never drift apart.
*/
const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800

/**
* `PersistenceCapabilities::CORE_CHAIN_TRUNCATION` (bit 15,
* `0x8000`; bit 14 is reserved for Core history restore). The one Kotlin
* home of this bit, like [CAPABILITY_CORE_SWEEP_REMOVAL].
*/
const val CAPABILITY_CORE_CHAIN_TRUNCATION: Long = 0x8000
}

/**
Expand Down Expand Up @@ -421,6 +428,47 @@ abstract class NativePersistenceBridge {
winnerMinedHeight: Int,
): Int = 0

/**
* One chain truncation: a fork replaced the chain above [height] and
* the wallet dropped what it recorded from the blocks above it.
* Descriptor `([BI[BI[BI[BI)I`.
*
* [txids] packs [txidCount] raw 32-byte txids of removed transactions,
* [unconfirmedTxids] packs [unconfirmedTxidCount] txids of
* InstantSend-locked transactions kept as unconfirmed, and
* [restoredOutpoints] packs [restoredOutpointCount] 36-byte outpoint
* keys (raw txid followed by a little-endian vout) of coins the removed
* transactions spent.
*
* Fired before every other core callback of the round, once per
* truncation and in order: the rest of the round describes the wallet
* after the last one, so a removed transaction can be stored again, in
* its new block, by the callbacks that follow. Applying one means
* deleting every UTXO a removed transaction created for this wallet,
* and the transaction itself unless another wallet of the same store
* still has a part in it, keeping each unconfirmed one in the
* InstantSend context with no block and this wallet's outputs of it
* unconfirmed and InstantSend-locked, marking each restored outpoint
* unspent, and lowering a stored sync height above [height] to it. A
* transaction or outpoint never stored is a no-op.
*
* The JNI layer wires the slot only when the concrete bridge OVERRIDES
* this method, and Rust attests the capability only for slot present
* AND [CAPABILITY_CORE_CHAIN_TRUNCATION] declared through
* [persistenceCapabilitiesBits]. Without that, native never sends a
* truncation, so this default is never reached for a wired slot.
*/
open fun onWalletChangesetChainTruncated(
walletId: ByteArray,
height: Int,
txids: ByteArray,
txidCount: Int,
unconfirmedTxids: ByteArray,
unconfirmedTxidCount: Int,
restoredOutpoints: ByteArray,
restoredOutpointCount: Int,
): Int = 0

/**
* The round's numeric chainlock height, fired on every round whose
* changeset carries a chainlock, after the changeset callback and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ class PlatformWalletPersistenceHandler(
CAPABILITY_WALLET_RESTORE or
CAPABILITY_DPNS_NAME_STATES or
CAPABILITY_TRACKED_ASSET_LOCKS or
CAPABILITY_CORE_SWEEP_REMOVAL
CAPABILITY_CORE_SWEEP_REMOVAL or
CAPABILITY_CORE_CHAIN_TRUNCATION

/**
* The single-thread executor created when no [dispatcher] is injected.
Expand Down Expand Up @@ -1282,6 +1283,85 @@ class PlatformWalletPersistenceHandler(
return !chainlockOverIsLock
}

// ── Chain truncations ─────────────────────────────────────────────

/** One chain truncation, unpacked from the JNI trampoline's flat arrays at the callback. */
private class ChainTruncation(
val height: Int,
val removedTxids: List<ByteArray>,
val unconfirmedTxids: List<ByteArray>,
val restoredOutpoints: List<ByteArray>,
)

/**
* Stage one chain truncation. Native fires this before the round's
* other core callbacks and the round replays its ops in order, so the
* truncation lands first and a transaction it removes can be stored
* again, in its new block, by the slices that follow. Outside a round
* it runs in its own transaction.
*/
override fun onWalletChangesetChainTruncated(
walletId: ByteArray,
height: Int,
txids: ByteArray,
txidCount: Int,
unconfirmedTxids: ByteArray,
unconfirmedTxidCount: Int,
restoredOutpoints: ByteArray,
restoredOutpointCount: Int,
): Int = guarded {
require(height >= 0) { "truncation height must not be negative" }
val truncation = ChainTruncation(
height = height,
removedTxids = unpackFixed(txids, txidCount, 32, "truncated txids"),
unconfirmedTxids = unpackFixed(unconfirmedTxids, unconfirmedTxidCount, 32, "unconfirmed txids"),
restoredOutpoints = unpackFixed(restoredOutpoints, restoredOutpointCount, 36, "restored outpoints"),
)
stage(walletId) { db -> applyChainTruncation(db, walletId, truncation) }
0
}

/**
* Cut the store back the way the wallet cut itself back when a fork
* replaced the chain above [ChainTruncation.height] — the Room mirror
* of the SQLite store's `apply_truncation`.
*
* Everything but the transaction row is this wallet's own. The row is
* shared across wallets and each wallet's round commits on its own, so
* another wallet may already have stored a removed transaction again,
* in its new block, by the time this truncation arrives: its coins are
* not this truncation's to take, and the row goes only once nothing
* references it. The row's block columns are the one thing wallets
* share; between two wallets' rounds they can show the branch the other
* wallet last wrote.
*
* A restored coin needs no veto from stored claims, unlike a sweep's
* release: its spender was confirmed, and no block the fork keeps can
* hold a second spend of it.
*/
private suspend fun applyChainTruncation(db: DashDatabase, walletId: ByteArray, truncation: ChainTruncation) {
// Drop stale post-deletion callbacks (can't resurrect a wallet).
if (db.walletDao().getByWalletId(walletId) == null) return
// Removed: this wallet's coins, links, claims and involvement
// first, then the rows nothing references any more.
chunked(truncation.removedTxids) { db.txoDao().deleteWalletTxosByTxids(it, walletId) }
chunked(truncation.removedTxids) { db.txoDao().detachWalletSpenders(it, walletId) }
chunked(truncation.removedTxids) { db.documentDao().deleteWalletClaimsBySpenders(walletId, it) }
chunked(truncation.removedTxids) { db.transactionDao().deleteWalletInvolvements(it, walletId) }
chunked(truncation.removedTxids) { db.transactionDao().deleteUnreferencedByTxids(it) }
// Kept: InstantSend-locked, so mined again. Stored until then the
// way a locked transaction no block holds yet is; the coins they
// spent stay spent.
chunked(truncation.unconfirmedTxids) { db.transactionDao().unconfirmByTxids(it) }
chunked(truncation.unconfirmedTxids) { db.txoDao().unconfirmInstantLockedByTxids(it, walletId) }
Comment on lines +1352 to +1356

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Do not globally demote a shared transaction row from one wallet's truncation

TransactionEntity is shared across wallets, but unconfirmByTxids updates the row globally without a wallet predicate. If wallet B has already persisted the transaction on its replacement branch and wallet A later processes a truncation retaining the same transaction, A's callback clears the shared block fields and changes the context to InstantSend for B as well, while B's replacement-branch TXOs remain confirmed. Swift has the same shared-row mutation through unconfirmTransaction. This leaves another wallet's persisted transaction history inconsistent with its own coins and causes the primary post-reorg history guarantee to depend on callback ordering. Demote the shared row only when no other wallet has a confirmed representation, or store confirmation context per wallet.

source: gpt-6.1-sol (phase2-reviewer: ffi-engineer)

// Restored: this wallet's coins the removed transactions spent.
chunked(truncation.restoredOutpoints) { db.txoDao().releaseByOutpoints(it, walletId) }
Comment thread
lklimek marked this conversation as resolved.
chunked(truncation.restoredOutpoints) {
db.documentDao().deleteWalletSweptTombstonesByOutpoints(walletId, it)
}
db.walletDao().lowerSyncedHeight(walletId, truncation.height, System.currentTimeMillis())
}

// ── Sweeps ────────────────────────────────────────────────────────

/** One sweep batch, unpacked from the JNI trampoline's flat arrays at the callback. */
Expand Down Expand Up @@ -4120,6 +4200,8 @@ class PlatformWalletPersistenceHandler(

internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long =
NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL
internal const val CAPABILITY_CORE_CHAIN_TRUNCATION: Long =
NativePersistenceBridge.CAPABILITY_CORE_CHAIN_TRUNCATION

private const val TAG = "DashPersistence"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,18 @@ interface DocumentDao {
)
suspend fun deleteWalletSweptTombstonesByOutpoints(walletId: ByteArray, outpoints: List<ByteArray>)

/**
* Delete [walletId]'s ordinary claims made by one of [spendingTxids] —
* its spend-before-funding rows for transactions a chain truncation
* removed. Tombstones are not claims of the removed transaction and
* stay. Chunked by the caller.
*/
@Query(
"DELETE FROM pending_inputs WHERE walletId = :walletId AND isSweptTombstone = 0 " +
"AND spendingTransactionTxid IN (:spendingTxids)",
)
suspend fun deleteWalletClaimsBySpenders(walletId: ByteArray, spendingTxids: List<ByteArray>)

/** Bulk insert of freshly minted tombstones; Room binds one row at a time. */
@Insert
suspend fun insertPendingInputs(rows: List<PendingInputEntity>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,46 @@ interface TransactionDao {
@Query("DELETE FROM transactions WHERE txid IN (:txids)")
suspend fun deleteByTxids(txids: List<ByteArray>)

/**
* Drop [walletId]'s accounts' involvement in [txids] — its part in
* transactions a chain truncation removed. Chunked by the caller.
*/
@Query(
"DELETE FROM transaction_account_involvements WHERE transactionTxid IN (:txids) " +
"AND accountId IN (SELECT id FROM accounts WHERE walletId = :walletId)"
)
suspend fun deleteWalletInvolvements(txids: List<ByteArray>, walletId: ByteArray)

/**
* Delete those of [txids] nothing references any more — the same test
* as [deleteOrphanTransactions], limited to the transactions one chain
* truncation removed. A row another wallet still has a coin, a spend,
* a claim or an involvement in stays. Chunked by the caller.
*/
@Query(
"DELETE FROM transactions WHERE txid IN (:txids) " +
"AND txid NOT IN (SELECT txid FROM txos WHERE txid IS NOT NULL) " +
"AND txid NOT IN (SELECT spendingTxid FROM txos WHERE spendingTxid IS NOT NULL) " +
"AND txid NOT IN " +
"(SELECT spendingTransactionTxid FROM pending_inputs " +
"WHERE spendingTransactionTxid IS NOT NULL) " +
"AND txid NOT IN (SELECT transactionTxid FROM transaction_account_involvements)"
)
suspend fun deleteUnreferencedByTxids(txids: List<ByteArray>)

/**
* Keep [txids] as unconfirmed and InstantSend-locked: a chain
* truncation dropped their block, and it keeps no other kind. Sets the
* InstantSend context, clears the block columns and touches nothing
* else. Chunked by the caller.
*/
@Query(
"UPDATE transactions SET context = 1, blockHeight = 0, blockHash = NULL, " +
"blockTimestamp = 0, blockPosition = 0, hasBlockPosition = 0 " +
"WHERE txid IN (:txids)"
)
suspend fun unconfirmByTxids(txids: List<ByteArray>)

/**
* Orphan sweep run after a wallet wipe (Swift `deleteWalletData`'s
* post-delete pass): drop transactions no longer referenced by any
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,39 @@ interface TxoDao {
@Query("DELETE FROM txos WHERE txid IN (:txids)")
suspend fun deleteByTxids(txids: List<ByteArray>)

/**
* Delete [walletId]'s TXOs created by one of [txids] — its own coins
* from transactions a chain truncation removed. Another wallet's coins
* from the same transactions are not this wallet's truncation to take:
* that wallet's round may already have stored them again, in their new
* block. Chunked by the caller.
*/
@Query("DELETE FROM txos WHERE txid IN (:txids) AND walletId = :walletId")
suspend fun deleteWalletTxosByTxids(txids: List<ByteArray>, walletId: ByteArray)

/**
* Drop [walletId]'s links to one of [spendingTxids] — the wallet-scoped
* form of [detachSpenders], for a chain truncation. Chunked by the
* caller.
*/
@Query(
"UPDATE txos SET spendingTxid = NULL, spendingInputIndex = NULL " +
"WHERE spendingTxid IN (:spendingTxids) AND walletId = :walletId",
)
suspend fun detachWalletSpenders(spendingTxids: List<ByteArray>, walletId: ByteArray)

/**
* Mark [walletId]'s TXOs created by one of [txids] unconfirmed and
* InstantSend-locked — its outputs of transactions a chain truncation
* kept after dropping their block. Spend state is not touched, nor are
* another wallet's outputs. Chunked by the caller.
*/
@Query(
"UPDATE txos SET isConfirmed = 0, isInstantLocked = 1, height = 0 " +
"WHERE txid IN (:txids) AND walletId = :walletId",
)
suspend fun unconfirmInstantLockedByTxids(txids: List<ByteArray>, walletId: ByteArray)

/**
* Delete the rows at [outpoints] outright — outputs of a transaction
* swept in this round that some loser claimed or some release named.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ interface WalletDao {
)
suspend fun advanceChainLockHeight(walletId: ByteArray, height: Int, nowMillis: Long): Int

/**
* Lower the stored synced height to [height] when it is above it — a
* chain truncation at [height]. Never raises it, and a narrow column
* write for the same reason as [advanceChainLockHeight].
*
* @param nowMillis epoch millis for the `lastUpdated` stamp.
* @return number of rows updated (0 when the wallet row is gone or its
* height is already at or below [height]).
*/
@Query(
"UPDATE wallets SET syncedHeight = :height, lastUpdated = :nowMillis " +
"WHERE walletId = :walletId AND syncedHeight > :height"
)
suspend fun lowerSyncedHeight(walletId: ByteArray, height: Int, nowMillis: Long): Int

@Delete
suspend fun delete(wallet: WalletEntity)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ data class PlatformWalletPersistenceCapabilities(
* is deliberately absent: Android never attests it).
*/
const val CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL
/**
* A stored core changeset's chain truncations are durably applied
* before the rest of their round. Mirrors
* `PersistenceCapabilities::CORE_CHAIN_TRUNCATION`; aliased to the
* bridge's declaration like [CORE_SWEEP_REMOVAL].
*/
const val CORE_CHAIN_TRUNCATION: Long = NativePersistenceBridge.CAPABILITY_CORE_CHAIN_TRUNCATION
}
}

Expand Down
Loading
Loading