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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -394,30 +394,165 @@ class DashDatabaseMigrationTest {
db.close()
}

/**
* v10 → v11 adds `txos.supersededByTxid` (nullable) and
* `pending_inputs.isSweptTombstone` (defaulted `false`) — both
* additive. Pre-existing rows in each table must survive and read back
* with the new columns at their defaults.
*/
@Test
fun migrate10To11AddsSweepClaimDurabilityColumns() {
val legacy = helper.createDatabase(dbName, 10)
legacy.execSQL(
"INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " +
"syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " +
"VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)",
)
legacy.execSQL(
"INSERT INTO transactions (txid, transactionData, context, blockHeight, " +
"blockTimestamp, blockPosition, hasBlockPosition, direction, " +
"transactionType, transactionTypeKind, netAmount, label, firstSeen, " +
"createdAt, lastUpdated) " +
"VALUES (x'02', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0)",
)
legacy.execSQL(
"INSERT INTO txos (outpoint, vout, amount, address, scriptPubKey, height, " +
"isCoinbase, isConfirmed, isInstantLocked, isLocked, isSpent, createdAt, " +
"lastUpdated, walletId, txid) " +
"VALUES (x'0201', 1, 1000, 'y', x'00', 0, 0, 0, 0, 0, 0, 0, 0, x'01', x'02')",
)
legacy.execSQL(
"INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, walletId, " +
"createdAt) VALUES (x'0301', 0, x'02', x'01', 0)",
)
legacy.close()

val db = helper.runMigrationsAndValidate(dbName, 11, true, DashDatabase.MIGRATION_10_11)
db.query("SELECT supersededByTxid FROM txos WHERE outpoint = x'0201'").use { c ->
assertTrue(c.moveToFirst())
assertTrue(c.isNull(0))
}
db.query("SELECT isSweptTombstone FROM pending_inputs WHERE outpoint = x'0301'").use { c ->
assertTrue(c.moveToFirst())
assertEquals(0, c.getInt(0))
}
db.close()
}

/**
* v10 → v11 adds `transactions.isGloballySwept` (defaulted `false`) —
* additive. Pre-existing rows must survive and read back not swept, and
* the flag must accept an explicit `true` on write, mirroring
* `migrate10To11AddsSweepClaimDurabilityColumns` above for the sibling
* v11 columns.
*/
@Test
fun migrate11To12AddsGlobalSweptFlag() {
val legacy = helper.createDatabase(dbName, 11)
legacy.execSQL(
"INSERT INTO transactions (txid, transactionData, context, blockHeight, " +
"blockTimestamp, blockPosition, hasBlockPosition, direction, " +
"transactionType, transactionTypeKind, netAmount, label, firstSeen, " +
"createdAt, lastUpdated) " +
"VALUES (x'02', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0)",
)
legacy.close()

val db = helper.runMigrationsAndValidate(dbName, 12, true, DashDatabase.MIGRATION_11_12)
db.query("SELECT isGloballySwept FROM transactions WHERE txid = x'02'").use { c ->
assertTrue(c.moveToFirst())
assertEquals(0, c.getInt(0))
}
db.execSQL(
"INSERT INTO transactions (txid, transactionData, context, blockHeight, " +
"blockTimestamp, blockPosition, hasBlockPosition, direction, " +
"transactionType, transactionTypeKind, netAmount, label, firstSeen, " +
"createdAt, lastUpdated, isGloballySwept) " +
"VALUES (x'03', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0, 1)",
)
db.query("SELECT isGloballySwept FROM transactions WHERE txid = x'03'").use { c ->
assertTrue(c.moveToFirst())
assertEquals(1, c.getInt(0))
}
db.close()
}

/**
* v12 → v13 adds `pending_inputs.winnerMinedHeight` and
* `wallets.lastAppliedChainLockHeight` (both nullable, no default) —
* additive. Pre-existing rows must survive and read back NULL
* (an unstamped tombstone is never collected, and no chainlock height
* means no finality boundary), and both columns must accept an
* explicit value on write.
*/
@Test
fun migrate12To13AddsWinnerHeightAndChainLockHeight() {
val legacy = helper.createDatabase(dbName, 12)
legacy.execSQL(
"INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " +
"walletId, createdAt, isSweptTombstone) " +
"VALUES (x'04', 0, x'05', x'06', 0, 1)",
)
legacy.execSQL(
"INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " +
"syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " +
"VALUES (x'06', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)",
)
legacy.close()

val db = helper.runMigrationsAndValidate(dbName, 13, true, DashDatabase.MIGRATION_12_13)
db.query("SELECT winnerMinedHeight FROM pending_inputs WHERE outpoint = x'04'").use { c ->
assertTrue(c.moveToFirst())
assertTrue("pre-migration tombstones read back unstamped", c.isNull(0))
}
db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'06'").use { c ->
assertTrue(c.moveToFirst())
assertTrue("pre-migration wallets have no chainlock height on record", c.isNull(0))
}
db.execSQL(
"INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " +
"walletId, createdAt, isSweptTombstone, winnerMinedHeight) " +
"VALUES (x'07', 0, x'05', x'06', 0, 1, 1234)",
)
db.query("SELECT winnerMinedHeight FROM pending_inputs WHERE outpoint = x'07'").use { c ->
assertTrue(c.moveToFirst())
assertEquals(1234, c.getInt(0))
}
db.execSQL("UPDATE wallets SET lastAppliedChainLockHeight = 4321 WHERE walletId = x'06'")
db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'06'").use { c ->
assertTrue(c.moveToFirst())
assertEquals(4321, c.getInt(0))
}
db.close()
}

/** The requested contiguous path from the pre-u64 v4 schema to latest. */
@Test
fun migrate4ToLatest() {
helper.createDatabase(dbName, 4).close()
helper.runMigrationsAndValidate(
dbName,
10,
13,
true,
DashDatabase.MIGRATION_4_5,
DashDatabase.MIGRATION_5_6,
DashDatabase.MIGRATION_6_7,
DashDatabase.MIGRATION_7_8,
DashDatabase.MIGRATION_8_9,
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
DashDatabase.MIGRATION_11_12,
DashDatabase.MIGRATION_12_13,
).close()
}

/** The full chain from v1 must also land on a valid v10 schema. */
/** The full chain from v1 must also land on a valid v13 schema. */
@Test
fun migrateAllTheWayFrom1() {
helper.createDatabase(dbName, 1).close()
helper.runMigrationsAndValidate(
dbName,
10,
13,
true,
DashDatabase.MIGRATION_1_2,
DashDatabase.MIGRATION_2_3,
Expand All @@ -428,6 +563,9 @@ class DashDatabaseMigrationTest {
DashDatabase.MIGRATION_7_8,
DashDatabase.MIGRATION_8_9,
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
DashDatabase.MIGRATION_11_12,
DashDatabase.MIGRATION_12_13,
).close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ abstract class NativePersistenceBridge {

open fun persistenceCapabilitiesBits(): Long = 0L

companion object {
/**
* `PersistenceCapabilities::CORE_SWEEP_REMOVAL` (bit 11, `0x800`).
* Declared here — on the class whose
* [onWalletChangesetTransactionsSwept] default consults it — so the
* fail-closed guard and the declaration a subclass makes through
* [persistenceCapabilitiesBits] can never drift apart.
* `PlatformWalletPersistenceHandler`'s capability constants alias
* this value.
*/
const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800
}

// ── Transactional bracketing ──────────────────────────────────────

/** `on_changeset_begin_fn` — descriptor `([B)I`. */
Expand Down Expand Up @@ -294,6 +307,94 @@ abstract class NativePersistenceBridge {
/** Close the current account bucket. Descriptor `([BI)I`. */
open fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0

/**
* Transactions the wallet removed in one sweep batch, as raw 32-byte
* txids, each paired by index with the transaction that settled its
* inputs, plus the outpoints this batch actually freed. Invoked once
* PER BATCH, in the round's emission order, after the per-account
* decomposition and only when the round swept something. The order is
* load-bearing, not cosmetic: batches are non-commutative — each
* release is true only of the wallet its own sweep saw, and a later
* batch can keep spent a coin an earlier one freed — so an
* implementation must apply every call's holds before its releases and
* must never fold calls together or reorder them.
* Descriptor `([B[[B[[B[[BI)I`.
*
* [winnerMinedHeight] is the winner's own mined block height for a
* block-context sweep, or -1 for an InstantSend-locked winner not yet
* mined (the sentinel is unambiguous — block heights are
* non-negative — and the handler maps it back to null). It keys the
* lifetime of the durable claim every non-released input retains: a
* stamped hold is collectible once the chainlock finality boundary
* reaches the stamp, while the null case leaves the SAME hold
* UNSTAMPED — an IS-locked winner has no mining deadline, so no
* boundary can prove the held input's funding delivered-or-never — and
* no collector may ever remove an unstamped hold: it resolves only
* through proof, when the funding TXO materializes it, a later
* block-context sweep re-stamps it, or a release deletes it. An
* implementation that drops the hold instead (either by skipping it
* for a -1 winner or by aging it out) deletes the only cross-restart
* carrier of a consumed coin's spend claim and later restores that
* coin as spendable.
*
* Each removed transaction was a recorded spend that its winner beat to
* one of its inputs, so it can never confirm. Every other slot on this
* bus is additive; this is the only removal, and an implementation that
* ignores it keeps dead rows that are handed back at the next load and
* re-create a balance the wallet has already corrected.
*
* [releasedOutpoints] holds 36-byte keys (raw txid followed by a
* little-endian vout) and is wallet-scoped, not attributed per removal:
* an implementation holds every input of every row it deletes, so it
* only needs to know which of them came free. Everything else it holds
* was taken by the transaction that won those inputs and must stay
* spent. The set cannot be inferred from [supersededBy] — that
* transaction may pay entirely to outside addresses and never be
* reported here at all.
*
* Native delivers these through the persistence extension's
* size-negotiated sweep callback (not the wallet-changeset struct, whose
* bare-pointer ABI cannot version itself), immediately after the
* changeset's own slots in the same round — and unconditionally: the
* trampoline is wired for every subclass, so "slot present" proves
* nothing about whether removals are actually applied. What Rust trusts
* is [persistenceCapabilitiesBits] carrying
* [CAPABILITY_CORE_SWEEP_REMOVAL]; a subclass overriding this must add
* that bit, and the default body below is what encodes the other half
* of that contract structurally. A subclass that declares the bit
* WITHOUT overriding has promised removals it silently swallows — and
* because the declaration also stops Rust stripping the watermark, the
* sync height would advance past a removal that never happened, the
* one permanent corruption the capability exists to prevent. The
* default therefore refuses the round in exactly that case (non-zero
* return, so `onChangesetEnd` rolls it back and the watermark cannot
* move). A subclass that declares nothing keeps the benign ignore:
* Rust already strips the watermark before its `store()`, so returning
* success costs nothing and preserves the round's additive slots.
*/
open fun onWalletChangesetTransactionsSwept(
walletId: ByteArray,
txids: Array<ByteArray>,
supersededBy: Array<ByteArray>,
releasedOutpoints: Array<ByteArray>,
winnerMinedHeight: Int,
): Int =
if (persistenceCapabilitiesBits() and CAPABILITY_CORE_SWEEP_REMOVAL != 0L) 1 else 0

/**
* The round's numeric chainlock height, fired on chainlock-advancing
* persistence rounds after the header slot. Descriptor `([BI)I`.
*
* The bincode chainlock blob on the header call is opaque to Kotlin,
* and this scalar is the half of the swept-tombstone collection
* boundary `min(chainlockHeight, syncedHeight)` an implementation
* cannot otherwise know. Purely additive: a host that ignores it
* simply never collects tombstones, which is the safe direction —
* holding a tombstone forever is junk, collecting one early is a
* wrongly-freed claim.
*/
open fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = 0

// ── Identities ────────────────────────────────────────────────────

/**
Expand Down
Loading
Loading