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
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import kotlinx.coroutines.runBlocking
import org.dashfoundation.dashsdk.Network
import org.dashfoundation.dashsdk.Sdk
import org.dashfoundation.dashsdk.config.SdkConfig
import org.dashfoundation.dashsdk.ffi.WalletManagerNative
import org.dashfoundation.dashsdk.persistence.DashDatabase
import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler
import org.dashfoundation.dashsdk.security.WalletStorage
import org.junit.After
import org.junit.Assert.assertEquals
Expand Down Expand Up @@ -259,4 +261,219 @@ class WalletManagerRoundTripTest {
assertEquals(1, state.getInt("ignoredSenders"))
}
}

/**
* Credit verdicts through REAL JNI (dashpay/platform#4638), then reopen.
*
* The JVM `PlatformWalletPersistenceHandlerTest` restart tests hand the
* handler its verdicts directly, so they stay green if the native side
* drops or mis-packs one. This round goes through
* `nativeTestDeliverUtxoCreditVerdictRound`: Rust builds the verdicts
* out of a `CoreChangeSet`, `FFIPersister::store` fires the verdict slot
* before the changeset, the JNI trampolines pack the 36-byte outpoints,
* codes and heights and the round's `utxos_added`, and the Kotlin
* handler stages and commits the rows. A fresh manager then restores
* from Room, and the engine's balance must count the credited coin
* only.
*
* Fixture, all paying the wallet's own BIP44 receive addresses:
* - tx 1 vout 0, [CREDITED]: no verdict, so it is restored.
* - tx 1 vout 1, [BORN_SPENT]: observed spent, so its row is born spent.
* - tx 1 vout 2, [DOOMED]: doomed, so its row is born spent.
* - tx 2 vout 0, [HELD]: stored earlier and then marked spent with no
* spender link (a sweep hold). Re-delivered with an uncredited
* verdict, it must stay spent. Without the verdict, the re-delivery
* would clear the row and the coin would come back.
*
* What a native fault does here: a dropped verdict (any of the three)
* restores that coin. A mis-packed outpoint (txid bytes or vout
* encoding) matches no row, so all three come back. Verdicts shifted
* by one output put a verdict on the credited coin. A code moved onto
* the wrong entry puts the context-free uncredited code on a fresh
* row, which is then stored unspent. A count or length mismatch fails
* the round, and the hook throws. Each case changes the asserted
* restore set; [droppedVerdictsRestoreThePhantomCoins] and
* [verdictsShiftedByOneOutputChangeTheRestoreSet] check that it does.
* Not covered: swapping the two born-spent codes, or the observed-spent
* height, which the Kotlin handler only checks for length.
*/
@Test
fun creditVerdictRoundThroughJniRestoresOnlyCreditedCoins() = runBlocking {
val outcome = deliverVerdictScenario(
verdicts = byteArrayOf(
CODE_CREDITED,
PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_OBSERVED_SPENT,
PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_DOOMED,
PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_UNCREDITED,
),
)

// Room, as the handler committed it from the JNI-delivered round.
assertEquals(
"every fixture output has a row",
setOf(CREDITED, BORN_SPENT, DOOMED, HELD),
outcome.spentByAmount.keys,
)
assertEquals(false, outcome.spentByAmount[CREDITED])
assertEquals("observed-spent verdict β†’ born spent", true, outcome.spentByAmount[BORN_SPENT])
assertEquals("doomed verdict β†’ born spent", true, outcome.spentByAmount[DOOMED])
assertEquals("uncredited verdict vetoes the clear", true, outcome.spentByAmount[HELD])
assertTrue("a verdict never mints a spender link", outcome.unlinked)

// The restore set the load callback hands Rust, and what the engine
// actually holds after a fresh manager loaded it.
assertEquals(listOf(CREDITED), outcome.restoredAmounts)
assertEquals(CREDITED, outcome.reloadedEngineBalance)
}

/**
* Control for [creditVerdictRoundThroughJniRestoresOnlyCreditedCoins]:
* the same round with its verdicts dropped restores every coin. So that
* test's assertions do catch a lost verdict, and its fixture is not
* passing for an unrelated reason.
*/
@Test
fun droppedVerdictsRestoreThePhantomCoins() = runBlocking {
val dropped = deliverVerdictScenario(
verdicts = byteArrayOf(CODE_CREDITED, CODE_CREDITED, CODE_CREDITED, CODE_CREDITED),
)
assertEquals(
listOf(CREDITED, BORN_SPENT, DOOMED, HELD),
dropped.restoredAmounts,
)
assertEquals(CREDITED + BORN_SPENT + DOOMED + HELD, dropped.reloadedEngineBalance)
}

/**
* Control for [creditVerdictRoundThroughJniRestoresOnlyCreditedCoins]:
* verdicts shifted by one output (a vout mis-pack) give a different
* restore set.
*/
@Test
fun verdictsShiftedByOneOutputChangeTheRestoreSet() = runBlocking {
val shifted = deliverVerdictScenario(
verdicts = byteArrayOf(
PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_OBSERVED_SPENT,
PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_DOOMED,
PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_UNCREDITED,
CODE_CREDITED,
),
)
// The credited coin is born spent, the uncredited code leaves a
// fresh row unspent, and the held coin is cleared and comes back.
assertEquals(listOf(DOOMED, HELD), shifted.restoredAmounts)
assertEquals(DOOMED + HELD, shifted.reloadedEngineBalance)
}

private class VerdictOutcome(
val spentByAmount: Map<Long, Boolean>,
val unlinked: Boolean,
val restoredAmounts: List<Long>,
val reloadedEngineBalance: Long,
)

/**
* Create the wallet, store [HELD] in a verdict-free round and mark it
* spent with no link, deliver the four-output round with [verdicts]
* (one per output, in the fixture order of the test KDoc) through the
* JNI hook, then read the result back from Room, from the restore
* callback and from a freshly loaded manager.
*/
private suspend fun deliverVerdictScenario(verdicts: ByteArray): VerdictOutcome {
val walletId: ByteArray
val receive: List<String>
PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager ->
walletId = manager.createWallet(
mnemonic = testMnemonic,
name = "verdict-round-trip",
createDefaultAccounts = true,
).walletId
val bip44 = db.accountDao().observeByWallet(walletId).first().single {
it.accountType == 0 && it.standardTag == 0 && it.accountIndex == 0
}
receive = db.coreAddressDao().observeByAccount(bip44.id).first()
.filter { it.poolTypeTag == 0 }
.sortedBy { it.addressIndex }
.map { it.address }
.take(4)
assertEquals("four BIP44 receive addresses persisted", 4, receive.size)
}

PlatformWalletPersistenceHandler(db, network = Network.TESTNET).use { handler ->
// Round 1: tx 2 alone, credited, so the HELD row exists.
WalletManagerNative.nativeTestDeliverUtxoCreditVerdictRound(
handler,
walletId,
txTags = intArrayOf(2),
addresses = arrayOf(receive[3]),
amounts = longArrayOf(HELD),
verdicts = byteArrayOf(CODE_CREDITED),
spentAtHeights = intArrayOf(0),
blockHeight = BLOCK_HEIGHT,
)
val held = db.txoDao().observeByWallet(walletId).first().single { it.amount == HELD }
assertEquals(false, held.isSpent)
// A sweep hold: spent, no spender link.
assertEquals(1, db.txoDao().markSpentByOutpoint(held.outpoint, java.util.Date()))

// Round 2: tx 1 (three outputs) plus tx 2 again, with the verdicts.
WalletManagerNative.nativeTestDeliverUtxoCreditVerdictRound(
handler,
walletId,
txTags = intArrayOf(1, 1, 1, 2),
addresses = arrayOf(receive[0], receive[1], receive[2], receive[3]),
amounts = longArrayOf(CREDITED, BORN_SPENT, DOOMED, HELD),
verdicts = verdicts,
spentAtHeights = IntArray(verdicts.size) { i ->
if (verdicts[i] ==
PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_OBSERVED_SPENT
) {
BLOCK_HEIGHT - 1
} else {
0
}
},
blockHeight = BLOCK_HEIGHT,
)
}

val rows = db.txoDao().observeByWallet(walletId).first()
val restored = PlatformWalletPersistenceHandler(db, network = Network.TESTNET).use { handler ->
handler.onLoadWalletList()
.single { it.walletId.contentEquals(walletId) }
.utxos
.map { it.valueDuffs }
.sorted()
}
val balance = PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { reloaded ->
reloaded.loadPersistedWallets()
assertNotNull(reloaded.wallet(forWalletId = walletId))
val balances = org.json.JSONArray(requireNotNull(reloaded.accountBalances(walletId)))
(0 until balances.length()).sumOf { i ->
val account = balances.getJSONObject(i)
account.getLong("confirmed") + account.getLong("unconfirmed") +
account.getLong("immature") + account.getLong("locked")
}
}
return VerdictOutcome(
spentByAmount = rows.associate { it.amount to it.isSpent },
unlinked = rows.all { it.spendingTxid == null },
restoredAmounts = restored,
reloadedEngineBalance = balance,
)
}

private companion object {
/** No verdict: the engine credited the output. */
const val CODE_CREDITED: Byte = 0

// Distinct amounts identify each fixture output in every read-back,
// and any subset of them sums to a distinct balance.
const val CREDITED = 100_000L
const val BORN_SPENT = 200_000L
const val DOOMED = 400_000L
const val HELD = 800_000L

const val BLOCK_HEIGHT = 1_000
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,31 @@ abstract class NativePersistenceBridge {
clearedIdentities: ByteArray,
): Int = 0

/**
* The engine's credit verdicts for the round (dashpay/platform#4638):
* every `Received` / `Change` output of the round's records that the
* engine did NOT credit to the owning account. Fired inside the
* begin/end bracket BEFORE the changeset callback, and only on rounds
* that carry at least one verdict, so an implementation has them in
* hand when the round's [onWalletChangesetUtxoAdded] entries arrive.
* Descriptor `([B[B[B[II)I`.
*
* [outpoints] is [count] 36-byte keys (32-byte wire-order txid plus a
* little-endian vout) packed into one array; [verdicts] holds one
* `UTXO_CREDIT_VERDICT_*` code per outpoint (1 observed spent, 2
* doomed, 3 uncredited) and [spentAtHeights] the observed spending
* block's height for code 1, 0 otherwise. Purely additive: a host that
* ignores the verdicts writes exactly what it wrote before the slot
* existed.
*/
open fun onWalletChangesetUtxoVerdicts(
walletId: ByteArray,
outpoints: ByteArray,
verdicts: ByteArray,
spentAtHeights: IntArray,
count: Int,
): Int = 0

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

/**
Expand Down
Loading
Loading