From 715aad2367577bdc682e88a0ef4a0c5fdb4929b5 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:58:48 -0400 Subject: [PATCH 1/2] feat(kotlin-sdk): tx-label & asset-lock-kind DAO resolver queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only DAO queries backing the app's transaction-label resolver. No schema change — every column read already exists in DashDatabase v9, so no migration and no new exported schema are required. TransactionDao: - transactionKindForTxid(wireTxid) / transactionKindForDisplayTxid(hex): resolve a tx's transactionTypeKind, used to label the withdraw/unshield case (AssetUnlock == 7), which has no asset_locks row. Adds a private displayHexToWireTxid() companion (explorer display hex → wire BLOB PK). AssetLockDao: - fundingTypeForTxid(txidDisplayHex): the fundingTypeRaw of the asset lock whose outPointHex PK is prefixed by the display txid. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/persistence/dao/AssetLockDao.kt | 16 ++++++ .../dashsdk/persistence/dao/TransactionDao.kt | 51 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt index 677e3ed4c4..2f957fbb34 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt @@ -74,6 +74,22 @@ interface AssetLockDao { @Query("SELECT * FROM asset_locks WHERE outPointHex = :outPointHex") suspend fun getByOutPointHex(outPointHex: String): AssetLockEntity? + /** + * Transaction-label resolver probe: the `fundingTypeRaw` of the asset + * lock whose outpoint belongs to [txidHex]. [txidHex] is the explorer + * DISPLAY txid hex (64 lowercase chars, wire order reversed) — the + * prefix of the `outPointHex` PK (`:`), matched + * via `LIKE ':%'`. A txid is a pure-hex string (no `%`/`_`), + * so the LIKE pattern carries no wildcards of its own. Returns null + * when no asset lock funds this tx (e.g. plain send, or an + * AssetUnlock/unshield — see [TransactionDao.transactionKindForTxid]). + */ + @Query( + "SELECT fundingTypeRaw FROM asset_locks " + + "WHERE outPointHex LIKE :txidHex || ':%' LIMIT 1" + ) + suspend fun fundingTypeForTxid(txidHex: String): Int? + @Upsert suspend fun upsert(assetLock: AssetLockEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index e212b808f1..322750d27a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt @@ -27,6 +27,35 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE txid = :txid") suspend fun getByTxid(txid: ByteArray): TransactionEntity? + /** + * Transaction-label resolver probe for the withdraw/unshield case, + * which has NO `asset_locks` row and is identified solely by + * `transactionTypeKind == 7` (AssetUnlock). + * + * [txidWire] is the raw little-endian WIRE txid — the exact + * [TransactionEntity.txid] PK form (a BLOB), i.e. the explorer display + * hex reversed then hex-decoded. When the caller holds the display hex + * (as the resolver does), prefer [transactionKindForDisplayTxid], which + * does the reversal. Returns null when no such tx is stored; + * `transactionTypeKind == 0xFF` (255) means "not yet populated". + */ + @Query("SELECT transactionTypeKind FROM transactions WHERE txid = :txidWire LIMIT 1") + suspend fun transactionKindForTxid(txidWire: ByteArray): Int? + + /** + * Convenience over [transactionKindForTxid] keyed by the explorer + * DISPLAY txid hex (64 lowercase chars, wire order reversed) the + * resolver already holds — the same display form used by + * [AssetLockDao.fundingTypeForTxid]. Reverses to wire order before the + * BLOB PK match. Returns null for malformed hex (not 64 hex chars) or + * when no such tx is stored. Not a Room query — a plain default method + * delegating to [transactionKindForTxid]. + */ + suspend fun transactionKindForDisplayTxid(txidDisplayHex: String): Int? { + val wire = displayHexToWireTxid(txidDisplayHex) ?: return null + return transactionKindForTxid(wire) + } + /** Timeline join helper — parents of a wallet's TXO set. */ @Query("SELECT * FROM transactions WHERE txid IN (:txids) ORDER BY firstSeen DESC") fun observeByTxids(txids: List): Flow> @@ -89,4 +118,26 @@ interface TransactionDao { /** StorageExplorer row count. */ @Query("SELECT COUNT(*) FROM transactions") fun count(): Flow + + companion object { + /** + * Explorer DISPLAY txid hex (wire order reversed, 64 lowercase + * chars) → raw little-endian WIRE txid, the [TransactionEntity.txid] + * PK form. Mirrors the txid half of [decodeOutPointHex]. Returns + * null for any non-64-char or non-hex input. + */ + private fun displayHexToWireTxid(displayHex: String): ByteArray? { + if (displayHex.length != 64) return null + val display = ByteArray(32) + for (i in 0 until 32) { + val hi = Character.digit(displayHex[i * 2], 16) + val lo = Character.digit(displayHex[i * 2 + 1], 16) + if (hi < 0 || lo < 0) return null + display[i] = ((hi shl 4) or lo).toByte() + } + val wire = ByteArray(32) + for (i in 0 until 32) wire[i] = display[31 - i] + return wire + } + } } From 176f8ed3eb2f70b18aa7b270e4fb5e6ab56cdd44 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:52:44 -0400 Subject: [PATCH 2/2] fix(kotlin-sdk): enforce 64-hex contract in fundingTypeForTxid + resolver Room tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: - AssetLockDao.fundingTypeForTxid no longer binds the caller's input as a LIKE pattern. The query now enforces the documented 64-hex contract itself (length + hex-only GLOB, lower()-canonicalized) and compares the exact 65-char `:` outPointHex prefix, so wildcard inputs like "%" or 64 underscores return null instead of an arbitrary row's funding type — matching transactionKindForDisplayTxid's null-on-malformed behavior. The DIP-0027 multi-vout LIMIT 1 selection is documented as value-stable (same-flow rows share fundingTypeRaw). - New TransactionLabelResolverDaoTest pins all three resolver contracts on the in-memory Room harness with a NON-palindromic txid (0x01..0x20), so the display->wire byte reversal in displayHexToWireTxid has a regression test: display hex resolves, the unreversed wire hex misses, uppercase is accepted, malformed/wildcard inputs return null, and multiple vouts sharing a txid stay value-stable. Verified: :sdk:testDebugUnitTest --tests '*TransactionLabelResolverDaoTest*' passes (KSP re-validated the rewritten query). Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/persistence/dao/AssetLockDao.kt | 26 ++- .../TransactionLabelResolverDaoTest.kt | 183 ++++++++++++++++++ 2 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/TransactionLabelResolverDaoTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt index 2f957fbb34..dddd64d56d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt @@ -77,16 +77,28 @@ interface AssetLockDao { /** * Transaction-label resolver probe: the `fundingTypeRaw` of the asset * lock whose outpoint belongs to [txidHex]. [txidHex] is the explorer - * DISPLAY txid hex (64 lowercase chars, wire order reversed) — the - * prefix of the `outPointHex` PK (`:`), matched - * via `LIKE ':%'`. A txid is a pure-hex string (no `%`/`_`), - * so the LIKE pattern carries no wildcards of its own. Returns null - * when no asset lock funds this tx (e.g. plain send, or an - * AssetUnlock/unshield — see [TransactionDao.transactionKindForTxid]). + * DISPLAY txid hex (64 chars, wire order reversed; uppercase input is + * canonicalized via `lower()`) — the prefix of the `outPointHex` PK + * (`:`). The query enforces the 64-hex input + * contract itself (length + hex-only GLOB, matching the + * null-on-malformed behavior of + * [TransactionDao.transactionKindForDisplayTxid]) and compares the + * exact 65-char `:` prefix rather than a LIKE pattern, so + * `%`/`_` in malformed input can never match arbitrary rows. Returns + * null for malformed input or when no asset lock funds this tx (e.g. + * plain send, or an AssetUnlock/unshield — see + * [TransactionDao.transactionKindForTxid]). + * + * DIP-0027 permits several asset-lock outputs in one transaction; + * those rows are created by the same funding flow and share a + * `fundingTypeRaw`, so the unordered `LIMIT 1` pick is value-stable. */ @Query( "SELECT fundingTypeRaw FROM asset_locks " + - "WHERE outPointHex LIKE :txidHex || ':%' LIMIT 1" + "WHERE length(:txidHex) = 64 " + + "AND lower(:txidHex) NOT GLOB '*[^0-9a-f]*' " + + "AND substr(outPointHex, 1, 65) = lower(:txidHex) || ':' " + + "LIMIT 1" ) suspend fun fundingTypeForTxid(txidHex: String): Int? diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/TransactionLabelResolverDaoTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/TransactionLabelResolverDaoTest.kt new file mode 100644 index 0000000000..0a12f984da --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/TransactionLabelResolverDaoTest.kt @@ -0,0 +1,183 @@ +package org.dashfoundation.dashsdk.persistence + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity +import org.dashfoundation.dashsdk.persistence.entities.TransactionEntity +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * In-memory Room contract tests for the three transaction-label resolver + * queries: [org.dashfoundation.dashsdk.persistence.dao.TransactionDao]'s + * `transactionKindForTxid` / `transactionKindForDisplayTxid` and + * [org.dashfoundation.dashsdk.persistence.dao.AssetLockDao]'s + * `fundingTypeForTxid`. + * + * The fixture txid is deliberately NON-palindromic (`0x01..0x20`), so the + * display↔wire byte reversal in `displayHexToWireTxid` is pinned: deleting + * (or double-applying) the reversal loop flips these assertions, which a + * symmetric fixture like `"ab" * 32` would not catch. + */ +@RunWith(RobolectricTestRunner::class) +class TransactionLabelResolverDaoTest { + + private lateinit var db: DashDatabase + + private val walletId = ByteArray(32) { 1 } + + /** Raw little-endian WIRE txid — the `transactions.txid` PK form. */ + private val wireTxid = ByteArray(32) { (it + 1).toByte() } + + /** Explorer DISPLAY hex: wire bytes reversed, lowercase. */ + private val displayHex = hex(wireTxid.reversedArray()) + + /** The unreversed hex of the wire bytes — display form of a DIFFERENT tx. */ + private val wireHexUnreversed = hex(wireTxid) + + /** `TransactionType` discriminant for AssetUnlock (withdraw/unshield). */ + private val kindAssetUnlock = 7 + + /** `AssetLockFundingType` discriminant for AssetLockAddressTopUp. */ + private val fundingTypeAddressTopUp = 4 + + @Before + fun setUp() { + db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) + } + + @After + fun tearDown() { + db.close() + } + + private fun hex(bytes: ByteArray): String = + bytes.joinToString("") { "%02x".format(it) } + + private suspend fun insertTransaction() { + db.transactionDao().upsert( + TransactionEntity( + txid = wireTxid, + transactionData = ByteArray(4), + transactionTypeKind = kindAssetUnlock, + ), + ) + } + + private suspend fun insertAssetLock(outPointHex: String, fundingTypeRaw: Int) { + db.assetLockDao().upsert( + AssetLockEntity( + outPointHex = outPointHex, + walletId = walletId, + transactionBytes = ByteArray(4), + fundingTypeRaw = fundingTypeRaw, + identityIndexRaw = 0, + amountDuffs = 10_000, + statusRaw = 1, + ), + ) + } + + // ── transactionKindForTxid (wire BLOB PK) ───────────────────────── + + @Test + fun transactionKindForTxidMatchesWireBytes() = runTest { + insertTransaction() + assertEquals(kindAssetUnlock, db.transactionDao().transactionKindForTxid(wireTxid)) + } + + @Test + fun transactionKindForTxidReturnsNullForUnknownTxid() = runTest { + insertTransaction() + assertNull(db.transactionDao().transactionKindForTxid(ByteArray(32) { 9 })) + } + + // ── transactionKindForDisplayTxid (display hex → wire reversal) ─── + + @Test + fun displayTxidLookupReversesToWireOrder() = runTest { + insertTransaction() + // Display hex resolves the row — pins the byte reversal on a + // non-palindromic txid. + assertEquals( + kindAssetUnlock, + db.transactionDao().transactionKindForDisplayTxid(displayHex), + ) + // The UNREVERSED wire hex must miss: it denotes a different + // (reversed) txid. If the reversal loop were deleted, this lookup + // would incorrectly succeed and the one above would fail. + assertNull(db.transactionDao().transactionKindForDisplayTxid(wireHexUnreversed)) + } + + @Test + fun displayTxidLookupAcceptsUppercaseHex() = runTest { + insertTransaction() + assertEquals( + kindAssetUnlock, + db.transactionDao().transactionKindForDisplayTxid(displayHex.uppercase()), + ) + } + + @Test + fun displayTxidLookupRejectsMalformedInput() = runTest { + insertTransaction() + assertNull(db.transactionDao().transactionKindForDisplayTxid("")) + assertNull(db.transactionDao().transactionKindForDisplayTxid(displayHex.dropLast(1))) + assertNull(db.transactionDao().transactionKindForDisplayTxid(displayHex + "00")) + assertNull( + db.transactionDao() + .transactionKindForDisplayTxid("z" + displayHex.drop(1)), + ) + } + + // ── fundingTypeForTxid (outPointHex prefix) ─────────────────────── + + @Test + fun fundingTypeForTxidMatchesExactOutpointPrefix() = runTest { + insertAssetLock("$displayHex:0", fundingTypeAddressTopUp) + assertEquals( + fundingTypeAddressTopUp, + db.assetLockDao().fundingTypeForTxid(displayHex), + ) + // Uppercase canonical txids are accepted (`lower()` in the query). + assertEquals( + fundingTypeAddressTopUp, + db.assetLockDao().fundingTypeForTxid(displayHex.uppercase()), + ) + // A different txid misses even with rows present. + assertNull(db.assetLockDao().fundingTypeForTxid(wireHexUnreversed)) + } + + @Test + fun fundingTypeForTxidRejectsWildcardAndMalformedInput() = runTest { + insertAssetLock("$displayHex:0", fundingTypeAddressTopUp) + // SQLite LIKE would have treated these as wildcards; the exact + // substr comparison + hex GLOB guard must return null instead of + // an arbitrary row's funding type. + assertNull(db.assetLockDao().fundingTypeForTxid("%")) + assertNull(db.assetLockDao().fundingTypeForTxid("_".repeat(64))) + assertNull(db.assetLockDao().fundingTypeForTxid("%".repeat(64))) + assertNull(db.assetLockDao().fundingTypeForTxid(displayHex.dropLast(1))) + assertNull(db.assetLockDao().fundingTypeForTxid("")) + } + + @Test + fun fundingTypeForTxidIsStableAcrossMultipleVoutsOfOneTx() = runTest { + // DIP-0027 permits several asset-lock outputs per tx; rows from the + // same funding flow share fundingTypeRaw, so LIMIT 1 stays + // value-stable regardless of which row the scan picks. + insertAssetLock("$displayHex:0", fundingTypeAddressTopUp) + insertAssetLock("$displayHex:1", fundingTypeAddressTopUp) + // An unrelated lock with a different funding type must not bleed in. + insertAssetLock("${hex(ByteArray(32) { 9 }.reversedArray())}:0", 5) + assertEquals( + fundingTypeAddressTopUp, + db.assetLockDao().fundingTypeForTxid(displayHex), + ) + } +}