Skip to content

Commit fa613f8

Browse files
refactor(platform-wallet)!: summarize dApp state transitions in platform-wallet and address review findings
Moves decoding and the approval summary of a dApp-provided state transition out of platform-wallet-ffi into platform-wallet (summarize_state_transition / StateTransitionSummary), on top of rs-dpp's exact untrusted decode and per-kind untagged decode (#4931). The FFI keeps only the C projection and no longer hardcodes bincode variant tags. What the user is shown before signing: trailing bytes and prefix decodes no longer count as a framing; user_fee_increase and a top-level complete flag (StateTransitionSummary::is_complete) are part of every summary; per-row details render document data, a document base's token payment and action fee agreement, token config update / emergency action / price schedule / claim distribution, quoted public notes, encrypted-note presence, group info and a mint to the default destination; data contract create / update and kinds without a describer carry the whole decoded value in details and are never complete. Clients: Kotlin initializes the native library before parsing, scrubs a derived Connect key discarded by cancellation, and reads the u32 kind name and complete flag (goldens regenerated); Swift reads the FFI constants for kinds, families and Connect sub-feature / purpose; a JNI test pins Kotlin's Connect constants to the FFI's. The Connect derive reuses the shared mnemonic resolver helper and erases the master key on every path. Key-limit conversion tests now live in rs-dpp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 8641e3e commit fa613f8

22 files changed

Lines changed: 2158 additions & 825 deletions

File tree

‎packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt‎

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.dashfoundation.dashsdk.identity
22

33
import java.nio.ByteBuffer
4+
import org.dashfoundation.dashsdk.Sdk
45
import org.dashfoundation.dashsdk.errors.mapNativeErrors
56
import org.dashfoundation.dashsdk.ffi.DashSDKException
67
import org.dashfoundation.dashsdk.ffi.TransactionsNative
@@ -18,6 +19,19 @@ import org.dashfoundation.dashsdk.ffi.TransactionsNative
1819
sealed class ParsedBatchedTransition {
1920
abstract val dataContractId: ByteArray
2021
abstract val action: String
22+
/**
23+
* Whether the typed fields describe every material field of the
24+
* transition. **A row with `complete == false` must not be approved from
25+
* the typed fields alone; the wallet must render [details].**
26+
*/
27+
abstract val complete: Boolean
28+
/**
29+
* The material fields the typed fields do not cover (document data, a
30+
* token config change and who it grants or revokes, an emergency action,
31+
* a price schedule, a distribution type, notes, group-action info),
32+
* rendered for display. Null when [complete].
33+
*/
34+
abstract val details: String?
2135
/** Credits or tokens the transition moves, when it moves any. */
2236
abstract val amount: Long?
2337
/**
@@ -39,6 +53,8 @@ sealed class ParsedBatchedTransition {
3953
override val action: String,
4054
override val amount: Long?,
4155
override val recipientId: ByteArray?,
56+
override val complete: Boolean,
57+
override val details: String?,
4258
) : ParsedBatchedTransition() {
4359
override fun equals(other: Any?): Boolean =
4460
other is Document &&
@@ -47,7 +63,9 @@ sealed class ParsedBatchedTransition {
4763
documentId.contentEquals(other.documentId) &&
4864
action == other.action &&
4965
amount == other.amount &&
50-
recipientId.contentEqualsNullable(other.recipientId)
66+
recipientId.contentEqualsNullable(other.recipientId) &&
67+
complete == other.complete &&
68+
details == other.details
5169

5270
override fun hashCode(): Int =
5371
listOf(dataContractId.contentHashCode(), documentType, documentId.contentHashCode(), action)
@@ -70,6 +88,10 @@ sealed class ParsedBatchedTransition {
7088
override val action: String,
7189
override val amount: Long?,
7290
override val recipientId: ByteArray?,
91+
/** A `DirectPurchase`'s token count ([amount] is its total agreed price). */
92+
val tokenCount: Long?,
93+
override val complete: Boolean,
94+
override val details: String?,
7395
) : ParsedBatchedTransition() {
7496
override fun equals(other: Any?): Boolean =
7597
other is Token &&
@@ -78,7 +100,10 @@ sealed class ParsedBatchedTransition {
78100
tokenContractPosition == other.tokenContractPosition &&
79101
action == other.action &&
80102
amount == other.amount &&
81-
recipientId.contentEqualsNullable(other.recipientId)
103+
recipientId.contentEqualsNullable(other.recipientId) &&
104+
tokenCount == other.tokenCount &&
105+
complete == other.complete &&
106+
details == other.details
82107

83108
override fun hashCode(): Int =
84109
listOf(dataContractId.contentHashCode(), tokenId.contentHashCode(), tokenContractPosition, action)
@@ -134,10 +159,10 @@ class ParsedDataContract(
134159
* `sign` request), decoded whatever its kind — the Kotlin mirror of Swift's
135160
* `ManagedPlatformWallet.ParsedStateTransition`.
136161
*
137-
* [serialized] holds the bytes that actually decoded, always in tagged DPP
138-
* framing (the variant tag is prepended when the input arrived tagless);
139-
* after approval, sign these rather than the input so what was shown is
140-
* what is signed. A `sign` request must arrive with `isSigned == false`
162+
* [serialized] holds the decoded transition re-serialized in tagged DPP
163+
* framing; after approval, sign these rather than the input so what was
164+
* shown is what is signed. When [complete] is false, [details] (or the
165+
* batch rows' details) must be shown before approval. A `sign` request must arrive with `isSigned == false`
141166
* and [ownerId] equal to the wallet's own identity; both checks are the
142167
* caller's.
143168
*/
@@ -148,8 +173,23 @@ class ParsedStateTransition(
148173
val ownerId: ByteArray?,
149174
/** Whether the transition already carries a signature. */
150175
val isSigned: Boolean,
176+
/**
177+
* Percentage added to the processing fee Platform charges (0 = none;
178+
* 65535 is about 656 times the base processing fee). Part of the signed
179+
* bytes: show a non-zero value on the sheet and refuse values above what
180+
* the wallet is willing to pay.
181+
*/
182+
val userFeeIncrease: Int,
183+
/** Whether [kind] shows every material field; computed in Rust. */
184+
val complete: Boolean,
151185
/** The decoded bytes, tagged. */
152186
val serialized: ByteArray,
187+
/**
188+
* A structured multi-line dump: of the whole transition for
189+
* [ParsedStateTransitionKind.Other], of the whole contract for a data
190+
* contract create / update. Null otherwise.
191+
*/
192+
val details: String?,
153193
val kind: ParsedStateTransitionKind,
154194
)
155195

@@ -180,6 +220,11 @@ object StateTransitionParser {
180220
*/
181221
fun parse(transitionBytes: ByteArray): ParsedStateTransition {
182222
require(transitionBytes.isNotEmpty()) { "transitionBytes must not be empty" }
223+
// This is a handle-free utility, so it may well be the first SDK
224+
// call in the process; load the native library and run dash_sdk_init
225+
// before touching JNI (as TransactionDecoder.decode does), otherwise
226+
// the external fun resolves to UnsatisfiedLinkError.
227+
Sdk.initialize()
183228
val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) }
184229
return try {
185230
parseBlob(blob)
@@ -206,10 +251,13 @@ object StateTransitionParser {
206251
internal fun parseBlob(blob: ByteArray): ParsedStateTransition {
207252
val buf = ByteBuffer.wrap(blob) // big-endian by default
208253
val kindTag = buf.get().toInt() and 0xFF
209-
val kindName = readString16(buf)
254+
val kindName = readString32(buf)
210255
val ownerId = if (readBool(buf)) readId32(buf) else null
211256
val isSigned = readBool(buf)
257+
val userFeeIncrease = buf.short.toInt() and 0xFFFF
258+
val complete = readBool(buf)
212259
val serialized = readBytes32Len(buf)
260+
val details = readString32(buf).ifEmpty { null }
213261

214262
val kind: ParsedStateTransitionKind = when (kindTag) {
215263
KIND_IDENTITY_UPDATE -> {
@@ -234,7 +282,9 @@ object StateTransitionParser {
234282
}
235283

236284
require(!buf.hasRemaining()) { "malformed parse blob: trailing bytes" }
237-
return ParsedStateTransition(kindName, ownerId, isSigned, serialized, kind)
285+
return ParsedStateTransition(
286+
kindName, ownerId, isSigned, userFeeIncrease, complete, serialized, details, kind,
287+
)
238288
}
239289

240290
private fun readPublicKey(buf: ByteBuffer): IdentityPubkey {
@@ -285,14 +335,25 @@ object StateTransitionParser {
285335
val documentId = readId32(buf)
286336
val amount = if (readBool(buf)) buf.long else null
287337
val recipient = if (readBool(buf)) readId32(buf) else null
288-
ParsedBatchedTransition.Document(dataContractId, documentType, documentId, action, amount, recipient)
338+
val tokenCount = if (readBool(buf)) buf.long else null
339+
require(tokenCount == null) { "malformed parse blob: token count on a document row" }
340+
val complete = readBool(buf)
341+
val details = readString32(buf).ifEmpty { null }
342+
ParsedBatchedTransition.Document(
343+
dataContractId, documentType, documentId, action, amount, recipient, complete, details,
344+
)
289345
}
290346
FAMILY_TOKEN -> {
291347
val position = buf.short.toInt() and 0xFFFF
292348
val tokenId = readId32(buf)
293349
val amount = if (readBool(buf)) buf.long else null
294350
val recipient = if (readBool(buf)) readId32(buf) else null
295-
ParsedBatchedTransition.Token(dataContractId, tokenId, position, action, amount, recipient)
351+
val tokenCount = if (readBool(buf)) buf.long else null
352+
val complete = readBool(buf)
353+
val details = readString32(buf).ifEmpty { null }
354+
ParsedBatchedTransition.Token(
355+
dataContractId, tokenId, position, action, amount, recipient, tokenCount, complete, details,
356+
)
296357
}
297358
else -> throw IllegalArgumentException("malformed parse blob: batched family $family")
298359
}
@@ -322,6 +383,9 @@ object StateTransitionParser {
322383
return String(bytes, Charsets.UTF_8)
323384
}
324385

386+
/** `u32 len + UTF-8 bytes`; an empty string encodes "none". */
387+
private fun readString32(buf: ByteBuffer): String = String(readBytes32Len(buf), Charsets.UTF_8)
388+
325389
/** `u32 len + bytes`. */
326390
private fun readBytes32Len(buf: ByteBuffer): ByteArray {
327391
val len = buf.int

‎packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,12 @@ class PlatformWalletManager(
687687
identityId: ByteArray,
688688
leaf: ByteArray,
689689
purpose: ConnectKeyPurpose? = null,
690-
): Pair<ByteArray, ByteArray> = teardownGate.op {
690+
): Pair<ByteArray, ByteArray> = teardownGate.opWithCleanupOnCancellation(
691+
// withContext's prompt cancellation can discard a completed derive
692+
// before the caller receives the pair it is documented to wipe; scrub
693+
// the private half on that handoff failure.
694+
cleanup = { pair -> pair.first.fill(0) },
695+
) {
691696
require(identityId.size == 32) { "identityId must be 32 bytes, got ${identityId.size}" }
692697
require(leaf.size == 32) { "leaf must be 32 bytes, got ${leaf.size}" }
693698
val pair = org.dashfoundation.dashsdk.errors.mapNativeErrors {

‎packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt‎

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,31 @@ class StateTransitionParserTest {
3030
assertEquals("IdentityUpdate", parsed.kindName)
3131
assertArrayEquals(ByteArray(32) { 0x11 }, parsed.ownerId)
3232
assertFalse(parsed.isSigned)
33+
assertEquals(0, parsed.userFeeIncrease)
34+
assertTrue(parsed.complete)
35+
assertNull("described kinds carry no common details", parsed.details)
3336
assertEquals("IdentityUpdate variant tag", 6, parsed.serialized.first().toInt())
3437

3538
val update = parsed.kind as ParsedStateTransitionKind.IdentityUpdate
3639
assertArrayEquals(ByteArray(32) { 0x11 }, update.identityId)
3740
assertEquals(listOf(4), update.disablePublicKeyIds)
38-
assertEquals(1, update.addPublicKeys.size)
41+
assertEquals(2, update.addPublicKeys.size)
3942

40-
val session = update.addPublicKeys.single()
43+
// The document-type-bound key: its bounds carry a variable-length
44+
// name before the limits flags, so the fields after it are pinned.
45+
val bound = update.addPublicKeys[1]
46+
assertEquals(19, bound.keyId)
47+
assertEquals(KeyPurpose.ENCRYPTION, bound.purpose)
48+
assertEquals(SecurityLevel.MEDIUM, bound.securityLevel)
49+
assertTrue(bound.readOnly)
50+
assertEquals(
51+
ContractBounds.SingleContractDocumentType(ByteArray(32) { 0x44 }, "profile"),
52+
bound.contractBounds,
53+
)
54+
assertNull(bound.totalBudget)
55+
assertNull(bound.expiresAt)
56+
57+
val session = update.addPublicKeys[0]
4158
assertEquals(18, session.keyId)
4259
assertEquals(KeyType.ECDSA_SECP256K1, session.keyType)
4360
assertEquals(KeyPurpose.AUTHENTICATION, session.purpose)
@@ -70,24 +87,110 @@ class StateTransitionParserTest {
7087
action = "Transfer",
7188
amount = 250L,
7289
recipientId = ByteArray(32) { 0x22 },
90+
tokenCount = null,
91+
complete = true,
92+
details = null,
7393
),
7494
),
7595
batch.transitions,
7696
)
7797
}
7898

7999
@Test
80-
fun `decodes the other kind with only the common fields`() {
81-
// kind 255, name "MasternodeVote", no owner, signed, empty serialized.
100+
fun `decodes a mixed document and token batch with completeness and details`() {
101+
val parsed = StateTransitionParser.parseBlob(golden("parsed_mixed_batch_v1.bin"))
102+
103+
assertEquals("DocumentsBatch([Create, Transfer, TokenDirectPurchase])", parsed.kindName)
104+
assertEquals(7, parsed.userFeeIncrease)
105+
assertFalse(parsed.complete)
106+
assertNull(parsed.details)
107+
108+
val batch = parsed.kind as ParsedStateTransitionKind.Batch
109+
assertEquals(3, batch.transitions.size)
110+
111+
// Document create: incomplete, its data rendered in details; the
112+
// fields after the two variable-length strings still line up.
113+
val create = batch.transitions[0] as ParsedBatchedTransition.Document
114+
assertEquals("Create", create.action)
115+
assertEquals("post", create.documentType)
116+
assertArrayEquals(ByteArray(32) { 0x0D }, create.documentId)
117+
assertNull(create.amount)
118+
assertNull(create.recipientId)
119+
assertFalse(create.complete)
120+
assertTrue(create.details!!.contains("\"message\""))
121+
122+
assertEquals(
123+
ParsedBatchedTransition.Document(
124+
dataContractId = ByteArray(32) { 0x42 },
125+
documentType = "profile",
126+
documentId = ByteArray(32) { 0x0D },
127+
action = "Transfer",
128+
amount = null,
129+
recipientId = ByteArray(32) { 0x22 },
130+
complete = true,
131+
details = null,
132+
),
133+
batch.transitions[1],
134+
)
135+
assertEquals(
136+
ParsedBatchedTransition.Token(
137+
dataContractId = ByteArray(32) { 0x42 },
138+
tokenId = ByteArray(32) { 0x77 },
139+
tokenContractPosition = 3,
140+
action = "DirectPurchase",
141+
amount = 100_000_000L,
142+
recipientId = null,
143+
tokenCount = 100L,
144+
complete = true,
145+
details = null,
146+
),
147+
batch.transitions[2],
148+
)
149+
}
150+
151+
/**
152+
* [StateTransitionParser.parse] is a handle-free utility and may be the
153+
* first SDK call in a process. Without the native library it must fail
154+
* inside [org.dashfoundation.dashsdk.Sdk.initialize] (the load step) and
155+
* not with an [UnsatisfiedLinkError] from the external fun itself, which
156+
* is what a forgotten initialize looks like. This host JVM has no
157+
* `libdash_sdk_jni.so`, so the load is what fails here; on a device the
158+
* same call path loads the library and parses.
159+
*/
160+
@Test
161+
fun `parse initializes the native library before entering JNI`() {
162+
val error = assertThrows(Throwable::class.java) {
163+
StateTransitionParser.parse(byteArrayOf(0x07, 0x00))
164+
}
165+
// System.loadLibrary failing is an UnsatisfiedLinkError whose message
166+
// names the library; a missing native method names the method.
167+
assertTrue(
168+
"expected the library load to fail, got ${error::class.simpleName}: ${error.message}",
169+
error is UnsatisfiedLinkError && error.message?.contains("dash_sdk_jni") == true,
170+
)
171+
assertFalse(
172+
"parse reached the external fun before loading the library",
173+
error.message?.contains("parseStateTransition") == true,
174+
)
175+
}
176+
177+
@Test
178+
fun `decodes the other kind with the common fields and details`() {
179+
// kind 255, name "MasternodeVote", no owner, signed, fee increase 3,
180+
// incomplete, empty serialized, details "vote".
82181
val name = "MasternodeVote".toByteArray()
83-
val blob = byteArrayOf(0xFF.toByte(), 0, name.size.toByte()) + name +
84-
byteArrayOf(0, 1, 0, 0, 0, 0)
182+
val details = "vote".toByteArray()
183+
val blob = byteArrayOf(0xFF.toByte(), 0, 0, 0, name.size.toByte()) + name +
184+
byteArrayOf(0, 1, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, details.size.toByte()) + details
85185
val parsed = StateTransitionParser.parseBlob(blob)
86186

87187
assertEquals("MasternodeVote", parsed.kindName)
88188
assertNull(parsed.ownerId)
89189
assertTrue(parsed.isSigned)
190+
assertEquals(3, parsed.userFeeIncrease)
191+
assertFalse(parsed.complete)
90192
assertEquals(0, parsed.serialized.size)
193+
assertEquals("vote", parsed.details)
91194
assertTrue(parsed.kind is ParsedStateTransitionKind.Other)
92195
}
93196

@@ -106,7 +209,8 @@ class StateTransitionParserTest {
106209
@Test
107210
fun `unknown kind tag throws rather than decoding as other`() {
108211
val name = "Whatever".toByteArray()
109-
val blob = byteArrayOf(77, 0, name.size.toByte()) + name + byteArrayOf(0, 0, 0, 0, 0, 0)
212+
val blob = byteArrayOf(77, 0, name.size.toByte()) + name +
213+
byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
110214
val error = assertThrows(IllegalArgumentException::class.java) {
111215
StateTransitionParser.parseBlob(blob)
112216
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)