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

Large diffs are not rendered by default.

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

/**
* v10 → v11 adds the four sweep-hold columns — `txos.supersededByTxid`
* (nullable), `pending_inputs.isSweptTombstone` (defaulted `false`),
* `pending_inputs.winnerMinedHeight` (nullable) and
* `wallets.lastAppliedChainLockHeight` (nullable) — plus the two
* `pending_inputs` indexes the sweep lookup and the collector use. All
* additive. Pre-existing rows in each table must survive and read back
* with the new columns at their defaults (an unstamped, non-tombstone
* row is never collected; a wallet with no chainlock height has no
* finality boundary), the nullable columns must accept an explicit
* value on write, and `runMigrationsAndValidate` pins the indexes
* against the exported 11.json.
*/
@Test
fun migrate10To11AddsSweepHoldColumnsAndIndexes() {
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, winnerMinedHeight FROM pending_inputs WHERE outpoint = x'0301'",
).use { c ->
assertTrue(c.moveToFirst())
assertEquals(0, c.getInt(0))
assertTrue("pre-migration rows read back unstamped", c.isNull(1))
}
db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'01'").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'01', 0, 1, 1234)",
)
db.query(
"SELECT isSweptTombstone, winnerMinedHeight FROM pending_inputs WHERE outpoint = x'07'",
).use { c ->
assertTrue(c.moveToFirst())
assertEquals(1, c.getInt(0))
assertEquals(1234, c.getInt(1))
}
db.execSQL("UPDATE wallets SET lastAppliedChainLockHeight = 4321 WHERE walletId = x'01'")
db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'01'").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,
11,
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,
).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 v11 schema. */
@Test
fun migrateAllTheWayFrom1() {
helper.createDatabase(dbName, 1).close()
helper.runMigrationsAndValidate(
dbName,
10,
11,
true,
DashDatabase.MIGRATION_1_2,
DashDatabase.MIGRATION_2_3,
Expand All @@ -428,6 +505,7 @@ class DashDatabaseMigrationTest {
DashDatabase.MIGRATION_7_8,
DashDatabase.MIGRATION_8_9,
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
).close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ abstract class NativePersistenceBridge {

open fun persistenceCapabilitiesBits(): Long = 0L

companion object {
/**
* `PersistenceCapabilities::CORE_SWEEP_REMOVAL` (bit 11, `0x800`).
* The one Kotlin home of this bit: `PlatformWalletPersistenceHandler`
* declares it through [persistenceCapabilitiesBits] and the public
* diagnostic mirror (`PlatformWalletPersistenceCapabilities`) aliases
* it, so the declaration and the mirror can never drift apart.
*/
const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800
}

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

/** `on_changeset_begin_fn` — descriptor `([B)I`. */
Expand Down Expand Up @@ -294,6 +305,98 @@ 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: [txidCount] raw
* 32-byte txids packed back to back in [txids], the single transaction
* [supersededBy] that settled their inputs, and the
* [releasedOutpointCount] 36-byte outpoint keys (raw txid followed by a
* little-endian vout, the same packing as `onWalletChangesetTransaction`'s
* `inputOutpoints`) packed in [releasedOutpoints] that this batch
* actually freed. Descriptor `([B[BI[B[BIZI)I`.
*
* Order within a round, stated once here (`store()` in
* `rs-platform-wallet-ffi/src/persistence.rs`): native fires the
* changeset callback — the header, then every account slice
* (transactions, then `utxos_added`, then `utxos_spent` per account) —
* then the chainlock-height slot ([onWalletChangesetChainLockHeight])
* when the round carries a chainlock, then this slot once PER BATCH in
* the round's emission order, and only when the round swept
* something. 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 apply the calls in order.
* It may buffer them until the round's end (the handler does, so the
* co-swept set spans the round), but it must never reorder them.
*
* [hasWinnerMinedHeight] says whether [winnerMinedHeight] is the
* winner's own mined block height (a block-context sweep) or
* meaningless (an InstantSend-locked winner not yet mined). 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 unmined 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 an unmined 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] 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). The JNI layer wires that
* slot only when the concrete bridge OVERRIDES this method
* (`rs-unified-sdk-jni/src/persistence.rs`, `bridge_overrides`), and
* Rust's own derivation — slot present AND
* [CAPABILITY_CORE_SWEEP_REMOVAL] declared through
* [persistenceCapabilitiesBits] — is the gate: a subclass that declares
* the bit without overriding never has the slot wired, so Rust strips
* the bit and the sync watermark with it rather than trusting a
* removal that would never be applied. This default is therefore the
* benign ignore, never reached in production for a wired slot.
*/
open fun onWalletChangesetTransactionsSwept(
walletId: ByteArray,
txids: ByteArray,
txidCount: Int,
supersededBy: ByteArray,
releasedOutpoints: ByteArray,
releasedOutpointCount: Int,
hasWinnerMinedHeight: Boolean,
winnerMinedHeight: Int,
): Int = 0

/**
* The round's numeric chainlock height, fired on every round whose
* changeset carries a chainlock, after the changeset callback and
* before the sweep batches (see [onWalletChangesetTransactionsSwept]
* for the full order). 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
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,32 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti
* document id, ownership/sale state, counterparty, document timestamps and
* marketplace reconciliation watermark. Defaults keep every legacy label an
* owned, unlisted row until the first native marketplace sync refreshes it.
*
* Version 11 (durable sweep holds): adds `txos.supersededByTxid`,
* `pending_inputs.isSweptTombstone`, `pending_inputs.winnerMinedHeight` and
* `wallets.lastAppliedChainLockHeight`, plus two `pending_inputs` indexes.
* A sweep's winner can beat a loser to an input whose funding TXO has not
* landed here yet, and until now the only record of that claim was the
* loser's own `pending_inputs` row, which cascades away with the loser it
* names — leaving the funding TXO's later arrival free to re-insert the
* outpoint as an ordinary unspent UTXO. `supersededByTxid` is the durable
* hold on a materialised coin (the SQLite store's `spent_in_txid`);
* `isSweptTombstone` marks the detached pending row that carries the same
* hold for a coin that has not materialised; `winnerMinedHeight` is the
* winner's own mined height stamped on that tombstone, the horizon the
* end-of-round collector compares against the chainlock finality boundary
* `min(chainlockHeight, syncedHeight)`; and `lastAppliedChainLockHeight`
* is the numeric chainlock height `onWalletChangesetChainLockHeight`
* delivers, the chainlock half of that boundary (the bincode chainlock
* blob is opaque here). The `spendingTxid` index serves the sweep's
* claimed-row lookup; the `(walletId, isSweptTombstone, winnerMinedHeight)`
* index covers the collector. All four columns are additive: every
* pre-migration row reads back as an ordinary, unstamped, non-tombstone
* entry, and a wallet with no recorded chainlock height has no boundary
* at all (nothing collects).
*/
@Database(
version = 10,
version = 11,
exportSchema = true,
entities = [
WalletEntity::class,
Expand Down Expand Up @@ -556,6 +579,43 @@ abstract class DashDatabase : RoomDatabase() {
}
}

/**
* v10 → v11: the four additive sweep-hold columns and the two
* `pending_inputs` indexes — see the version-11 class doc above.
* `isSweptTombstone` is defaulted so every existing row reads as
* "not a tombstone"; the other three are nullable and need no
* default (pre-migration tombstones read back unstamped and are
* never collected; the chainlock height starts NULL, so no
* boundary exists until `onWalletChangesetChainLockHeight`
* records one). Column order = entity field order, and the index
* SQL is the exported schema's verbatim so Room's validation of a
* migrated database passes.
*/
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `txos` ADD COLUMN `supersededByTxid` BLOB")
db.execSQL(
"ALTER TABLE `pending_inputs` ADD COLUMN `isSweptTombstone` " +
"INTEGER NOT NULL DEFAULT 0",
)
db.execSQL(
"ALTER TABLE `pending_inputs` ADD COLUMN `winnerMinedHeight` INTEGER",
)
db.execSQL(
"ALTER TABLE `wallets` ADD COLUMN `lastAppliedChainLockHeight` INTEGER",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` " +
"ON `pending_inputs` (`spendingTxid`)",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS " +
"`index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` " +
"ON `pending_inputs` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)",
)
}
}

/**
* Build the on-disk database. WAL is Room's default journal mode on
* API 16+; writes go through the persistence handler inside
Expand All @@ -574,6 +634,7 @@ abstract class DashDatabase : RoomDatabase() {
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
)
.build()

Expand Down
Loading
Loading