diff --git a/Cargo.lock b/Cargo.lock index 42621c52582..5ae8cc96d2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6512,6 +6512,7 @@ dependencies = [ "dash-network", "dashcore", "dashpay-contract", + "dpp", "jni 0.21.1", "key-wallet-ffi", "log", diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt index 29be7860bdf..cdef46fe9fe 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt @@ -153,6 +153,31 @@ internal object IdentityNative { keyIndex: Int, ): Array + /** + * DashPay Connect key derivation at the DIP-13 sub-feature paths + * `m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']` + * (dashpay/dips#191). Bridges `dash_sdk_derive_connect_key_with_resolver`, + * a pure resolver-keyed derive like the slot derives above. + * + * @param subFeature 6 (session authentication; [leaf] is the request + * id) or 7 (app encryption; [leaf] is the bound contract's id). + * @param identityId the identity's 32-byte id (a DIP-14 hardened child). + * @param leaf the 32-byte leaf (a DIP-14 hardened child). + * @param purpose 0 for no purpose level (session authentication), or + * the half of an encryption pair as one more hardened child: 1 + * ENCRYPTION, 2 DECRYPTION. Anything else throws. + * @return `[privateKey(32), publicKey(33)]`. + */ + external fun deriveConnectKeyWithResolver( + networkOrd: Int, + walletId: ByteArray, + resolverHandle: Long, + subFeature: Int, + identityId: ByteArray, + leaf: ByteArray, + purpose: Int, + ): Array + /** * Register a new identity funded from the wallet's Core balance. The * single FFI entry point the registration coordinator's body invokes. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 7c3b6e6c6a7..dca16a452e6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -17,6 +17,17 @@ package org.dashfoundation.dashsdk.ffi */ internal object TransactionsNative { + /** + * Decode any DPP state transition (a `dash-st:` payload or a DashPay + * Connect `sign` request) into the packed big-endian blob documented + * in `rs-unified-sdk-jni/src/parse_state_transition.rs`, decoded by + * [org.dashfoundation.dashsdk.identity.StateTransitionParser.parseBlob]. + * Bridges `platform_wallet_parse_state_transition`. Accepts tagged and + * Yappr's tagless framing; never signs or broadcasts. Throws + * [DashSDKException] on undecodable bytes. + */ + external fun parseStateTransition(transitionBytes: ByteArray): ByteArray + /** * Add public keys and/or disable existing key ids on an identity, * signing the resulting `IdentityUpdateTransition` via [signerHandle] diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt new file mode 100644 index 00000000000..cb4d9449ce7 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt @@ -0,0 +1,334 @@ +package org.dashfoundation.dashsdk.identity + +import java.nio.ByteBuffer +import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.TransactionsNative + +/** + * One transition inside a parsed `BatchTransition`, in batch order — the + * Kotlin mirror of Swift's `ManagedPlatformWallet.ParsedBatchedTransition`. + * + * [action] is the rs-dpp action name (`Create`, `Replace`, `Delete`, + * `Transfer`, `Purchase`, `UpdatePrice`, `IndexOnlyDelete` for documents; + * `Burn`, `Mint`, `Transfer`, `Freeze`, `Unfreeze`, `DestroyFrozenFunds`, + * `Claim`, `EmergencyAction`, `ConfigUpdate`, `DirectPurchase`, + * `SetPriceForDirectPurchase` for tokens). + */ +sealed class ParsedBatchedTransition { + abstract val dataContractId: ByteArray + abstract val action: String + /** Credits or tokens the transition moves, when it moves any. */ + abstract val amount: Long? + /** + * The identity on the other side of the transition, when there is one. + * Interpret it against [action]: a transfer's recipient, a mint's + * issued-to identity, or the identity whose tokens a freeze / unfreeze / + * destroy acts on. + */ + abstract val recipientId: ByteArray? + + /** + * A document transition. [amount] is a purchase price or an + * update-price value; [recipientId] is a transfer's new owner. + */ + class Document( + override val dataContractId: ByteArray, + val documentType: String, + val documentId: ByteArray, + override val action: String, + override val amount: Long?, + override val recipientId: ByteArray?, + ) : ParsedBatchedTransition() { + override fun equals(other: Any?): Boolean = + other is Document && + dataContractId.contentEquals(other.dataContractId) && + documentType == other.documentType && + documentId.contentEquals(other.documentId) && + action == other.action && + amount == other.amount && + recipientId.contentEqualsNullable(other.recipientId) + + override fun hashCode(): Int = + listOf(dataContractId.contentHashCode(), documentType, documentId.contentHashCode(), action) + .hashCode() + + override fun toString(): String = + "Document(action=$action, documentType=$documentType, amount=$amount)" + } + + /** + * A token transition. [amount] is the transferred / minted / burned + * token count or a direct purchase's total agreed price; [recipientId] + * is a transfer's recipient, a mint's issued-to identity, or the frozen + * identity of a freeze / unfreeze / destroy. + */ + class Token( + override val dataContractId: ByteArray, + val tokenId: ByteArray, + val tokenContractPosition: Int, + override val action: String, + override val amount: Long?, + override val recipientId: ByteArray?, + ) : ParsedBatchedTransition() { + override fun equals(other: Any?): Boolean = + other is Token && + dataContractId.contentEquals(other.dataContractId) && + tokenId.contentEquals(other.tokenId) && + tokenContractPosition == other.tokenContractPosition && + action == other.action && + amount == other.amount && + recipientId.contentEqualsNullable(other.recipientId) + + override fun hashCode(): Int = + listOf(dataContractId.contentHashCode(), tokenId.contentHashCode(), tokenContractPosition, action) + .hashCode() + + override fun toString(): String = + "Token(action=$action, position=$tokenContractPosition, amount=$amount)" + } +} + +/** The typed summary of a parsed state transition, discriminated by kind. */ +sealed class ParsedStateTransitionKind { + /** Key registration / revocation (`IdentityUpdateTransition`). */ + class IdentityUpdate( + val identityId: ByteArray, + val addPublicKeys: List, + val disablePublicKeyIds: List, + ) : ParsedStateTransitionKind() + + /** Document and token operations (`BatchTransition`). */ + class Batch( + val ownerId: ByteArray, + val transitions: List, + ) : ParsedStateTransitionKind() + + /** `IdentityCreditTransferTransition`. */ + class CreditTransfer( + val identityId: ByteArray, + val recipientId: ByteArray, + val amount: Long, + ) : ParsedStateTransitionKind() + + /** `DataContractCreateTransition`. */ + class DataContractCreate(val contract: ParsedDataContract) : ParsedStateTransitionKind() + + /** `DataContractUpdateTransition`. */ + class DataContractUpdate(val contract: ParsedDataContract) : ParsedStateTransitionKind() + + /** Any other kind; see [ParsedStateTransition.kindName]. */ + object Other : ParsedStateTransitionKind() +} + +/** Inspectable fields of a parsed data contract create / update. */ +class ParsedDataContract( + val contractId: ByteArray, + val ownerId: ByteArray, + /** Document type names the contract defines, as the contract orders them. */ + val documentTypeNames: List, +) + +/** + * One parsed state transition (a `dash-st:` payload or a DashPay Connect + * `sign` request), decoded whatever its kind — the Kotlin mirror of Swift's + * `ManagedPlatformWallet.ParsedStateTransition`. + * + * [serialized] holds the bytes that actually decoded, always in tagged DPP + * framing (the variant tag is prepended when the input arrived tagless); + * after approval, sign these rather than the input so what was shown is + * what is signed. A `sign` request must arrive with `isSigned == false` + * and [ownerId] equal to the wallet's own identity; both checks are the + * caller's. + */ +class ParsedStateTransition( + /** rs-dpp `StateTransition::name()`, e.g. `IdentityUpdate`, `MasternodeVote`. */ + val kindName: String, + /** The identity the transition acts for; null for kinds that name none. */ + val ownerId: ByteArray?, + /** Whether the transition already carries a signature. */ + val isSigned: Boolean, + /** The decoded bytes, tagged. */ + val serialized: ByteArray, + val kind: ParsedStateTransitionKind, +) + +/** + * Decode-any-kind state transition parser — the Android analog of Swift's + * `ManagedPlatformWallet.parseStateTransition`. One FFI call + * ([TransactionsNative.parseStateTransition]) returns a packed blob that + * [parseBlob] turns into a [ParsedStateTransition]; the blob layout is + * documented in `rs-unified-sdk-jni/src/parse_state_transition.rs`. + */ +object StateTransitionParser { + + /** `ParsedStateTransitionFFI::kind` values (`PARSED_STATE_TRANSITION_KIND_*`). */ + private const val KIND_IDENTITY_UPDATE = 1 + private const val KIND_BATCH = 2 + private const val KIND_CREDIT_TRANSFER = 3 + private const val KIND_DATA_CONTRACT_CREATE = 4 + private const val KIND_DATA_CONTRACT_UPDATE = 5 + private const val KIND_OTHER = 255 + + private const val FAMILY_DOCUMENT = 0 + private const val FAMILY_TOKEN = 1 + + /** + * Decode [transitionBytes] (tagged or Yappr's tagless framing). Never + * signs or broadcasts. Throws the mapped native error on undecodable + * bytes. + */ + fun parse(transitionBytes: ByteArray): ParsedStateTransition { + require(transitionBytes.isNotEmpty()) { "transitionBytes must not be empty" } + val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) } + return try { + parseBlob(blob) + } catch (e: RuntimeException) { + // A blob the JNI layer produced but this decoder cannot read is + // a Rust/Kotlin layout drift, not caller input; surface it as + // the same exception type native failures use so callers have + // one error path. + throw DashSDKException( + BLOB_DECODE_ERROR_CODE, + "parsed state transition blob could not be decoded: ${e.message}", + ) + } + } + + /** Mirrors the `99` internal-marshalling code the JNI layer throws with. */ + private const val BLOB_DECODE_ERROR_CODE = 99 + + /** + * Decode the packed blob the JNI layer returns. Internal + pure so it is + * host-JVM unit-testable without the native library + * (`StateTransitionParserTest` decodes the goldens the Rust side pins). + */ + internal fun parseBlob(blob: ByteArray): ParsedStateTransition { + val buf = ByteBuffer.wrap(blob) // big-endian by default + val kindTag = buf.get().toInt() and 0xFF + val kindName = readString16(buf) + val ownerId = if (readBool(buf)) readId32(buf) else null + val isSigned = readBool(buf) + val serialized = readBytes32Len(buf) + + val kind: ParsedStateTransitionKind = when (kindTag) { + KIND_IDENTITY_UPDATE -> { + val identityId = readId32(buf) + val added = List(readCount(buf, "added keys")) { readPublicKey(buf) } + val disabled = List(readCount(buf, "disabled keys")) { buf.int } + ParsedStateTransitionKind.IdentityUpdate(identityId, added, disabled) + } + KIND_BATCH -> { + val batchOwner = readId32(buf) + val transitions = List(readCount(buf, "batched transitions")) { readBatched(buf) } + ParsedStateTransitionKind.Batch(batchOwner, transitions) + } + KIND_CREDIT_TRANSFER -> + ParsedStateTransitionKind.CreditTransfer(readId32(buf), readId32(buf), buf.long) + KIND_DATA_CONTRACT_CREATE -> + ParsedStateTransitionKind.DataContractCreate(readDataContract(buf)) + KIND_DATA_CONTRACT_UPDATE -> + ParsedStateTransitionKind.DataContractUpdate(readDataContract(buf)) + KIND_OTHER -> ParsedStateTransitionKind.Other + else -> throw IllegalArgumentException("malformed parse blob: unknown kind $kindTag") + } + + require(!buf.hasRemaining()) { "malformed parse blob: trailing bytes" } + return ParsedStateTransition(kindName, ownerId, isSigned, serialized, kind) + } + + private fun readPublicKey(buf: ByteBuffer): IdentityPubkey { + val keyId = buf.int + val keyType = buf.get().toInt() and 0xFF + val purpose = buf.get().toInt() and 0xFF + val securityLevel = buf.get().toInt() and 0xFF + val readOnly = readBool(buf) + val boundsKind = buf.get().toInt() and 0xFF + val dataLen = buf.short.toInt() and 0xFFFF + val data = ByteArray(dataLen).also { buf.get(it) } + val boundsId = if (boundsKind != 0) readId32(buf) else null + val docType = if (boundsKind == 2) readString16(buf) else null + val flags = buf.get().toInt() and 0xFF + val totalBudget = if (flags and 1 != 0) buf.long else null + val expiresAt = if (flags and 2 != 0) buf.long else null + + val bounds: ContractBounds? = when (boundsKind) { + 0 -> null + 1 -> ContractBounds.SingleContract(boundsId!!) + 2 -> ContractBounds.SingleContractDocumentType(boundsId!!, docType!!) + 3 -> ContractBounds.ContractGroup(boundsId!!) + else -> throw IllegalArgumentException("malformed parse blob: contract bounds kind $boundsKind") + } + return IdentityPubkey( + keyId = keyId, + keyType = KeyType.entries.firstOrNull { it.ffiValue == keyType } + ?: throw IllegalArgumentException("malformed parse blob: key type $keyType"), + purpose = KeyPurpose.entries.firstOrNull { it.ffiValue == purpose } + ?: throw IllegalArgumentException("malformed parse blob: purpose $purpose"), + securityLevel = SecurityLevel.entries.firstOrNull { it.ffiValue == securityLevel } + ?: throw IllegalArgumentException("malformed parse blob: security level $securityLevel"), + pubkeyBytes = data, + readOnly = readOnly, + contractBounds = bounds, + totalBudget = totalBudget, + expiresAt = expiresAt, + ) + } + + private fun readBatched(buf: ByteBuffer): ParsedBatchedTransition { + val family = buf.get().toInt() and 0xFF + val dataContractId = readId32(buf) + val action = readString16(buf) + return when (family) { + FAMILY_DOCUMENT -> { + val documentType = readString16(buf) + val documentId = readId32(buf) + val amount = if (readBool(buf)) buf.long else null + val recipient = if (readBool(buf)) readId32(buf) else null + ParsedBatchedTransition.Document(dataContractId, documentType, documentId, action, amount, recipient) + } + FAMILY_TOKEN -> { + val position = buf.short.toInt() and 0xFFFF + val tokenId = readId32(buf) + val amount = if (readBool(buf)) buf.long else null + val recipient = if (readBool(buf)) readId32(buf) else null + ParsedBatchedTransition.Token(dataContractId, tokenId, position, action, amount, recipient) + } + else -> throw IllegalArgumentException("malformed parse blob: batched family $family") + } + } + + private fun readDataContract(buf: ByteBuffer): ParsedDataContract { + val contractId = readId32(buf) + val ownerId = readId32(buf) + val names = List(readCount(buf, "document type names")) { readString16(buf) } + return ParsedDataContract(contractId, ownerId, names) + } + + private fun readBool(buf: ByteBuffer): Boolean = buf.get().toInt() != 0 + + private fun readId32(buf: ByteBuffer): ByteArray = ByteArray(32).also { buf.get(it) } + + private fun readCount(buf: ByteBuffer, what: String): Int { + val count = buf.int + require(count >= 0) { "malformed parse blob: negative $what count" } + return count + } + + /** `u16 len + UTF-8 bytes`. */ + private fun readString16(buf: ByteBuffer): String { + val len = buf.short.toInt() and 0xFFFF + val bytes = ByteArray(len).also { buf.get(it) } + return String(bytes, Charsets.UTF_8) + } + + /** `u32 len + bytes`. */ + private fun readBytes32Len(buf: ByteBuffer): ByteArray { + val len = buf.int + require(len >= 0) { "malformed parse blob: negative length" } + return ByteArray(len).also { buf.get(it) } + } +} + +private fun ByteArray?.contentEqualsNullable(other: ByteArray?): Boolean = + if (this == null || other == null) this === other else contentEquals(other) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index bce8324ef49..a18ed364506 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -640,6 +640,71 @@ class PlatformWalletManager( pair[0] to pair[1] } + /** + * DIP-13 sub-features DashPay Connect v2 keys live under + * (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`), + * registered by the DIP-13 amendment dashpay/dips#191. Mirror of + * Swift's `ManagedPlatformWallet.ConnectSubFeature`. + */ + enum class ConnectSubFeature(val value: Int) { + /** Session authentication key: the leaf is the connect request id; no purpose level. */ + SESSION_AUTHENTICATION(6), + /** + * App encryption key pair: the leaf is the bound data contract's id; + * the purpose level is a [ConnectKeyPurpose]. + */ + APP_ENCRYPTION(7), + } + + /** + * The trailing `purpose'` level of the app-encryption path: the DPP + * purpose discriminant of the half being derived. Only these two exist + * in the DIP-13 amendment, so the type makes any other value (in + * particular 0, the FFI's "no purpose level") unrepresentable. Mirror + * of Swift's `ManagedPlatformWallet.ConnectKeyPurpose`. + */ + enum class ConnectKeyPurpose(val value: Int) { + ENCRYPTION(1), + DECRYPTION(2), + } + + /** + * Derive a DashPay Connect key at + * `m/9'/coin'/5'/'/0'/'/'[/']` + * from the wallet's seed, resolved through the mnemonic resolver — the + * Android analog of Swift's `deriveConnectKey(subFeature:identityId:leaf:purpose:)`. + * [identityId] and [leaf] are DIP-14 256-bit hardened children, so no + * wallet-local counter is an input; [purpose], when given, appends one + * more hardened child (the encryption sub-feature splits its pair with + * [ConnectKeyPurpose.ENCRYPTION] / [ConnectKeyPurpose.DECRYPTION]; the + * authentication sub-feature passes null, meaning no purpose level). + * + * @return `(privateKey(32), publicKey(33))`. Caller zeroes the private half. + */ + suspend fun deriveConnectKey( + walletId: ByteArray, + subFeature: ConnectSubFeature, + identityId: ByteArray, + leaf: ByteArray, + purpose: ConnectKeyPurpose? = null, + ): Pair = teardownGate.op { + require(identityId.size == 32) { "identityId must be 32 bytes, got ${identityId.size}" } + require(leaf.size == 32) { "leaf must be 32 bytes, got ${leaf.size}" } + val pair = org.dashfoundation.dashsdk.errors.mapNativeErrors { + org.dashfoundation.dashsdk.ffi.IdentityNative.deriveConnectKeyWithResolver( + network.ffiValue, + walletId, + mnemonicResolver.nativeHandle, + subFeature.value, + identityId, + leaf, + purpose?.value ?: 0, + ) + } + check(pair.size == 2) { "connect key derive returned ${pair.size} elements" } + pair[0] to pair[1] + } + /** * Identity registration / discovery / DPNS-name bridge. Stateless * wrapper over the identity JNI surface; callers thread the wallet diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt new file mode 100644 index 00000000000..43e72161677 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt @@ -0,0 +1,115 @@ +package org.dashfoundation.dashsdk.identity + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Host-JVM tests of [StateTransitionParser.parseBlob] — the Kotlin half of + * the JNI decode contract. The golden blobs under `resources/golden/` are + * the EXACT bytes the Rust side produces and pins in + * `rs-unified-sdk-jni/src/parse_state_transition.rs` + * (`*_blob_round_trips_and_is_pinned_for_kotlin`, which `include_bytes!` + * these same files), so the layout is verified from both ends without + * loading the native library. + */ +class StateTransitionParserTest { + + private fun golden(name: String): ByteArray = + javaClass.getResourceAsStream("/golden/$name") + .use { requireNotNull(it) { "golden fixture $name missing" }.readBytes() } + + @Test + fun `decodes an identity update registering a limited group-bound session key`() { + val parsed = StateTransitionParser.parseBlob(golden("parsed_identity_update_v1.bin")) + + assertEquals("IdentityUpdate", parsed.kindName) + assertArrayEquals(ByteArray(32) { 0x11 }, parsed.ownerId) + assertFalse(parsed.isSigned) + assertEquals("IdentityUpdate variant tag", 6, parsed.serialized.first().toInt()) + + val update = parsed.kind as ParsedStateTransitionKind.IdentityUpdate + assertArrayEquals(ByteArray(32) { 0x11 }, update.identityId) + assertEquals(listOf(4), update.disablePublicKeyIds) + assertEquals(1, update.addPublicKeys.size) + + val session = update.addPublicKeys.single() + assertEquals(18, session.keyId) + assertEquals(KeyType.ECDSA_SECP256K1, session.keyType) + assertEquals(KeyPurpose.AUTHENTICATION, session.purpose) + assertEquals(SecurityLevel.HIGH, session.securityLevel) + assertFalse(session.readOnly) + assertArrayEquals(ByteArray(33) { 0x03 }, session.pubkeyBytes) + assertEquals(ContractBounds.ContractGroup(ByteArray(32) { 0x66 }), session.contractBounds) + assertEquals(10_000_000_000L, session.totalBudget) + assertEquals(1_800_000_000_000L, session.expiresAt) + assertTrue(session.hasLimits) + } + + @Test + fun `decodes a batch carrying a token transfer`() { + val parsed = StateTransitionParser.parseBlob(golden("parsed_token_transfer_batch_v1.bin")) + + assertEquals("DocumentsBatch([TokenTransfer])", parsed.kindName) + assertArrayEquals(ByteArray(32) { 0x21 }, parsed.ownerId) + assertFalse(parsed.isSigned) + assertEquals("Batch variant tag", 2, parsed.serialized.first().toInt()) + + val batch = parsed.kind as ParsedStateTransitionKind.Batch + assertArrayEquals(ByteArray(32) { 0x21 }, batch.ownerId) + assertEquals( + listOf( + ParsedBatchedTransition.Token( + dataContractId = ByteArray(32) { 0x42 }, + tokenId = ByteArray(32) { 0x77 }, + tokenContractPosition = 3, + action = "Transfer", + amount = 250L, + recipientId = ByteArray(32) { 0x22 }, + ), + ), + batch.transitions, + ) + } + + @Test + fun `decodes the other kind with only the common fields`() { + // kind 255, name "MasternodeVote", no owner, signed, empty serialized. + val name = "MasternodeVote".toByteArray() + val blob = byteArrayOf(0xFF.toByte(), 0, name.size.toByte()) + name + + byteArrayOf(0, 1, 0, 0, 0, 0) + val parsed = StateTransitionParser.parseBlob(blob) + + assertEquals("MasternodeVote", parsed.kindName) + assertNull(parsed.ownerId) + assertTrue(parsed.isSigned) + assertEquals(0, parsed.serialized.size) + assertTrue(parsed.kind is ParsedStateTransitionKind.Other) + } + + @Test + fun `truncated and trailing-garbage blobs throw`() { + val golden = golden("parsed_token_transfer_batch_v1.bin") + assertThrows(RuntimeException::class.java) { + StateTransitionParser.parseBlob(golden.copyOf(golden.size - 5)) + } + val error = assertThrows(IllegalArgumentException::class.java) { + StateTransitionParser.parseBlob(golden + byteArrayOf(0)) + } + assertTrue(error.message!!.contains("trailing")) + } + + @Test + fun `unknown kind tag throws rather than decoding as other`() { + val name = "Whatever".toByteArray() + val blob = byteArrayOf(77, 0, name.size.toByte()) + name + byteArrayOf(0, 0, 0, 0, 0, 0) + val error = assertThrows(IllegalArgumentException::class.java) { + StateTransitionParser.parseBlob(blob) + } + assertTrue(error.message!!.contains("unknown kind")) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin new file mode 100644 index 00000000000..4f41448de1e Binary files /dev/null and b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin differ diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin new file mode 100644 index 00000000000..8b2f4a77217 Binary files /dev/null and b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin differ diff --git a/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs new file mode 100644 index 00000000000..655eaf73984 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs @@ -0,0 +1,487 @@ +//! DashPay Connect key derivation at the DIP-13 sub-feature paths +//! (dashpay/dips#191), driven by the resolver-backed seed. +//! +//! Both DashPay Connect v2 keys live under new DIP-13 sub-features with +//! DIP-14 256-bit hardened leaves, so no wallet-local counter is an input +//! and two devices restored from one seed derive the same key: +//! +//! ```text +//! session auth: m / 9' / coin' / 5' / 6' / 0' / identityId' / requestId' +//! app encryption: m / 9' / coin' / 5' / 7' / 0' / identityId' / contractId' / purpose' +//! ``` +//! +//! The wallet derives every key; the app never sees the seed. This module +//! is the FFI mirror of +//! [`platform_wallet::wallet::identity::network::derive_connect_keypair_from_master`] +//! and follows the resolver contract of +//! [`crate::dash_sdk_derive_identity_key_at_slot_with_resolver`]: the +//! mnemonic is pulled through the Swift/Kotlin-owned `MnemonicResolver` +//! for the duration of the call only, in a zeroized buffer. + +use std::ffi::c_void; +use std::os::raw::c_char; + +use key_wallet::bip32::ExtendedPrivKey; +use platform_wallet::wallet::identity::network::derive_connect_keypair_from_master; +use zeroize::Zeroizing; + +use crate::error::*; +use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; +use crate::types::{read_identifier, FFINetwork, Network}; +use crate::{check_ptr, unwrap_result_or_return}; +use rs_sdk_ffi::{ + mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, +}; + +/// `sub_feature` for the DashPay Connect session authentication key. The +/// leaf is the connect request id (`hash256` of the app's ephemeral public +/// key); `purpose` is `0`. +pub const CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION: u32 = + platform_wallet::wallet::identity::network::CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION; + +/// `sub_feature` for the DashPay Connect app encryption key pair. The leaf +/// is the id of the data contract the key is bound to; `purpose` is the +/// DPP purpose discriminant, `1` ENCRYPTION or `2` DECRYPTION. +pub const CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION: u32 = + platform_wallet::wallet::identity::network::CONNECT_SUB_FEATURE_APP_ENCRYPTION; + +/// `purpose` for the ENCRYPTION half of an app encryption pair (the DPP +/// `Purpose::ENCRYPTION` discriminant). +pub const CONNECT_KEY_PURPOSE_ENCRYPTION: u32 = 1; + +/// `purpose` for the DECRYPTION half of an app encryption pair (the DPP +/// `Purpose::DECRYPTION` discriminant). +pub const CONNECT_KEY_PURPOSE_DECRYPTION: u32 = 2; + +/// A derived DashPay Connect keypair. Plain old data: the caller copies +/// what it needs and calls [`dash_sdk_derive_connect_key_free`] to wipe +/// the private scalar. +#[repr(C)] +pub struct ConnectDerivedKeyFFI { + /// Raw 32-byte secp256k1 private scalar. Inline so the free path can + /// zeroize it without chasing a pointer. + pub private_key_bytes: [u8; 32], + /// Compressed secp256k1 public key, always 33 bytes. + pub public_key_bytes: [u8; 33], +} + +impl ConnectDerivedKeyFFI { + pub const fn empty() -> Self { + Self { + private_key_bytes: [0u8; 32], + public_key_bytes: [0u8; 33], + } + } +} + +impl Default for ConnectDerivedKeyFFI { + fn default() -> Self { + Self::empty() + } +} + +/// Derive the DashPay Connect key at +/// `m/9'/coin'/5'/'/0'/'/'[/']` +/// from the mnemonic the resolver returns for `wallet_id_bytes`. +/// +/// - `network` picks the coin type (`5'` mainnet, `1'` otherwise), as the +/// existing identity derivations do. +/// - `sub_feature` is `6` (session authentication) or `7` (app +/// encryption); any 31-bit value derives, the protocol decides which +/// are meaningful. +/// - `identity_id_bytes` and `leaf_bytes` are 32-byte DIP-14 hardened +/// children: the identity's own id, and the request id or bound +/// contract id. +/// - `purpose` is `0` for no purpose level (the session authentication +/// path), or the DPP purpose discriminant of the half being derived: +/// `1` ENCRYPTION or `2` DECRYPTION, appended as one further hardened +/// child. The DIP-13 amendment defines exactly those two, so any other +/// value is refused with `ErrorInvalidParameter` rather than derived +/// into a key nothing will ever look for. +/// +/// The derived key is written to `out_key`; call +/// [`dash_sdk_derive_connect_key_free`] when done with it. +/// +/// # Safety +/// - `wallet_id_bytes`, `identity_id_bytes` and `leaf_bytes` must point to +/// 32 readable bytes each. +/// - `mnemonic_resolver_handle` must be a live handle from +/// `dash_sdk_mnemonic_resolver_create`. +/// - `out_key` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_derive_connect_key_with_resolver( + network: FFINetwork, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + sub_feature: u32, + identity_id_bytes: *const u8, + leaf_bytes: *const u8, + purpose: u32, + out_key: *mut ConnectDerivedKeyFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_key); + *out_key = ConnectDerivedKeyFFI::empty(); + + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(leaf_bytes); + + let purpose = match purpose { + 0 => None, + CONNECT_KEY_PURPOSE_ENCRYPTION | CONNECT_KEY_PURPOSE_DECRYPTION => Some(purpose), + other => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "purpose {other} is not a DashPay Connect key purpose (0 = none, \ + 1 = ENCRYPTION, 2 = DECRYPTION)" + ), + ); + } + }; + + let identity_id = unwrap_result_or_return!(read_identifier(identity_id_bytes)); + let leaf: [u8; 32] = std::slice::from_raw_parts(leaf_bytes, 32) + .try_into() + .expect("from_raw_parts(_, 32) always yields exactly 32 bytes"); + + let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = + Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); + let mut mnemonic_len: usize = 0; + + let resolver = &*mnemonic_resolver_handle; + let resolver_vtable = &*resolver.vtable; + let rc = (resolver_vtable.resolve)( + resolver.ctx as *const c_void, + wallet_id_bytes, + mnemonic_buf.as_mut_ptr() as *mut c_char, + MNEMONIC_RESOLVER_BUFFER_CAPACITY, + &mut mnemonic_len, + ); + match rc { + x if x == mnemonic_resolver_result::SUCCESS => {} + x if x == mnemonic_resolver_result::NOT_FOUND => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: no mnemonic stored for the supplied wallet_id", + ); + } + x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: mnemonic exceeded the FFI buffer capacity", + ); + } + _ => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: failed (other / Keychain access error)", + ); + } + } + if mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver: reported length exceeds the FFI buffer capacity", + ); + } + + let mnemonic_str = unwrap_result_or_return!(std::str::from_utf8(&mnemonic_buf[..mnemonic_len])); + let mnemonic = unwrap_result_or_return!(parse_mnemonic_any_language(mnemonic_str)); + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + + let kw_network: Network = network.into(); + let master = unwrap_result_or_return!(ExtendedPrivKey::new_master(kw_network, seed.as_ref())); + + let derived = unwrap_result_or_return!(derive_connect_keypair_from_master( + &master, + kw_network, + sub_feature, + &identity_id, + leaf, + purpose, + )); + + (*out_key).private_key_bytes = *derived.private_key; + (*out_key).public_key_bytes = derived.public_key; + + PlatformWalletFFIResult::ok() +} + +/// Wipe a key populated by [`dash_sdk_derive_connect_key_with_resolver`]. +/// The struct owns no heap memory; this zeroizes the private scalar in +/// place (a volatile write the optimizer cannot elide) and clears the +/// public key. Safe on a null pointer and on an already-wiped key. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_derive_connect_key_free(out_key: *mut ConnectDerivedKeyFFI) { + if out_key.is_null() { + return; + } + use zeroize::Zeroize; + (*out_key).private_key_bytes.zeroize(); + (*out_key).public_key_bytes = [0u8; 33]; +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::prelude::Identifier; + use key_wallet::mnemonic::Mnemonic; + use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + use std::ffi::CStr; + + /// English BIP-39 test vector (all-zero entropy); the same fixture the + /// platform-wallet derivation tests pin vectors for. + const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const IDENTITY: [u8; 32] = [0x35; 32]; + const LEAF: [u8; 32] = [0x6B; 32]; + + unsafe extern "C" fn test_resolve( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + let phrase = TEST_MNEMONIC.as_bytes(); + if phrase.len() + 1 > out_capacity { + return mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn not_found_resolve( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + _out_buf: *mut c_char, + _out_capacity: usize, + _out_len: *mut usize, + ) -> i32 { + mnemonic_resolver_result::NOT_FOUND + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + + fn derive( + resolver: *mut MnemonicResolverHandle, + network: FFINetwork, + sub_feature: u32, + leaf: [u8; 32], + purpose: u32, + ) -> (PlatformWalletFFIResult, ConnectDerivedKeyFFI) { + let wallet_id = [0x07u8; 32]; + let mut out = ConnectDerivedKeyFFI::empty(); + let result = unsafe { + dash_sdk_derive_connect_key_with_resolver( + network, + wallet_id.as_ptr(), + resolver, + sub_feature, + IDENTITY.as_ptr(), + leaf.as_ptr(), + purpose, + &mut out, + ) + }; + (result, out) + } + + /// The FFI derives exactly what the platform-wallet primitive derives + /// from the same seed, so the two cannot drift; and the same inputs + /// always give the same key. + #[test] + fn derives_the_library_vector_deterministically() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), test_resolve, noop_destroy) + }; + + let (result, first) = derive( + resolver, + FFINetwork::Testnet, + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + LEAF, + 0, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + let (result, second) = derive( + resolver, + FFINetwork::Testnet, + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + LEAF, + 0, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(first.private_key_bytes, second.private_key_bytes); + assert_eq!(first.public_key_bytes, second.public_key_bytes); + + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).unwrap(); + let master = ExtendedPrivKey::new_master(Network::Testnet, &mnemonic.to_seed("")).unwrap(); + let expected = derive_connect_keypair_from_master( + &master, + Network::Testnet, + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + &Identifier::from(IDENTITY), + LEAF, + None, + ) + .unwrap(); + assert_eq!(first.private_key_bytes, *expected.private_key); + assert_eq!(first.public_key_bytes, expected.public_key); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Leaf, purpose and sub-feature each change the key; `purpose == 0` + /// appends nothing, so it differs from `purpose == 1` under the + /// encryption sub-feature. + #[test] + fn different_leaves_purposes_and_sub_features_give_different_keys() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), test_resolve, noop_destroy) + }; + let key = |sub_feature, leaf, purpose| { + let (result, out) = derive(resolver, FFINetwork::Testnet, sub_feature, leaf, purpose); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + out.public_key_bytes + }; + + let auth = key(CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, LEAF, 0); + let auth_other_leaf = key( + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + [0x6C; 32], + 0, + ); + let enc_unpurposed = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 0); + let enc = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 1); + let dec = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 2); + + let all = [auth, auth_other_leaf, enc_unpurposed, enc, dec]; + for (i, a) in all.iter().enumerate() { + for b in &all[i + 1..] { + assert_ne!(a, b, "connect keys collided (index {i})"); + } + } + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn free_zeroizes_and_is_idempotent() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), test_resolve, noop_destroy) + }; + let (result, mut out) = derive( + resolver, + FFINetwork::Mainnet, + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, + LEAF, + 2, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_ne!(out.private_key_bytes, [0u8; 32]); + + unsafe { dash_sdk_derive_connect_key_free(&mut out) }; + assert_eq!(out.private_key_bytes, [0u8; 32]); + assert_eq!(out.public_key_bytes, [0u8; 33]); + unsafe { dash_sdk_derive_connect_key_free(&mut out) }; + unsafe { dash_sdk_derive_connect_key_free(std::ptr::null_mut()) }; + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn resolver_miss_is_a_wallet_operation_error() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), not_found_resolve, noop_destroy) + }; + let (mut result, out) = derive( + resolver, + FFINetwork::Testnet, + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + LEAF, + 0, + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); + assert!(message.contains("no mnemonic stored"), "{message}"); + assert_eq!(out.private_key_bytes, [0u8; 32]); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn null_resolver_is_a_null_pointer_error() { + let mut out = ConnectDerivedKeyFFI::empty(); + let wallet_id = [0x07u8; 32]; + let result = unsafe { + dash_sdk_derive_connect_key_with_resolver( + FFINetwork::Testnet, + wallet_id.as_ptr(), + std::ptr::null_mut(), + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + IDENTITY.as_ptr(), + LEAF.as_ptr(), + 0, + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// The identity id is read through `read_identifier`, whose null check + /// runs before the resolver is touched, so a dangling resolver handle + /// is never dereferenced here. + #[test] + fn null_identity_id_is_an_error_before_the_resolver_is_used() { + let mut out = ConnectDerivedKeyFFI::empty(); + let wallet_id = [0x07u8; 32]; + let result = unsafe { + dash_sdk_derive_connect_key_with_resolver( + FFINetwork::Testnet, + wallet_id.as_ptr(), + std::ptr::dangling_mut(), + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + std::ptr::null(), + LEAF.as_ptr(), + 0, + &mut out, + ) + }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.private_key_bytes, [0u8; 32]); + } + + /// Only the two DIP-13 purposes (and 0 for none) derive; anything else + /// is refused before the seed is resolved. + #[test] + fn unknown_purpose_is_an_invalid_parameter() { + let mut out = ConnectDerivedKeyFFI::empty(); + let wallet_id = [0x07u8; 32]; + let mut result = unsafe { + dash_sdk_derive_connect_key_with_resolver( + FFINetwork::Testnet, + wallet_id.as_ptr(), + std::ptr::dangling_mut(), + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, + IDENTITY.as_ptr(), + LEAF.as_ptr(), + 3, + &mut out, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); + assert!(message.contains("purpose 3"), "{message}"); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + } +} diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs index 7dbda21ff29..ec6385447f8 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs @@ -116,9 +116,24 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// the credits its transitions may take from the identity over its /// lifetime, when `has_total_budget`; `expires_at`, the block time in /// milliseconds from which it can no longer sign, when -/// `has_expires_at`. Only AUTHENTICATION keys below MASTER may carry -/// them; a row with neither flag registers a version 0 key, the same -/// bytes as ever. +/// `has_expires_at`. A row with either flag set is registered as an +/// `IdentityPublicKeyInCreation::V1` (the limits are part of the +/// signable bytes, so the identity signs what it grants); a row with +/// neither flag registers a version 0 key, the same bytes as ever. The +/// `0` values behind an unset flag are ignored, so a caller that +/// encodes "no limit" as `0` must leave the flag false: a budget of +/// literally zero is refused by consensus +/// (`InvalidIdentityPublicKeyBudgetError`, 10537). +/// +/// Consensus rules the FFI deliberately does not duplicate (see +/// `docs/protocol/authentication-key-limits.md`): limits are allowed +/// only on AUTHENTICATION keys with a security level below MASTER +/// (`IdentityPublicKeyLimitsNotAllowedError`, 10536), a budget must be +/// non-zero (10537), and an expiry must lie after the registering +/// block's time (`IdentityPublicKeyAlreadyExpiredError`, 40219, a paid +/// failure; seconds instead of milliseconds is the usual way there). +/// Platform enforces all three at validation; the FFI passes the row +/// through so the error the caller sees is the chain's own. /// /// All pointers are borrowed for the call duration only — the /// FFI does not retain or free them. @@ -949,6 +964,94 @@ mod tests { assert_eq!(map.len(), 2); } + /// A row with a budget, an expiry, or both decodes to a version 1 key + /// carrying exactly those limits; a row with neither flag stays version + /// 0 so identities that do not use limits keep their historical bytes. + /// The `0` behind an unset flag is not read. + #[test] + fn key_with_row_limits_builds_v1_only_when_a_limit_is_set() { + use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; + + let pk = [0x02u8; 33]; + let base = || { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 7, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(pk.to_vec()), + disabled_at: None, + }) + }; + + let mut unlimited = ffi_row(7, &pk); + unlimited.total_budget = 5; // ignored: has_total_budget is false + unlimited.expires_at = 5; // ignored: has_expires_at is false + let key = key_with_row_limits(base(), &unlimited); + assert!(matches!(key, IdentityPublicKey::V0(_))); + assert_eq!(key.total_budget(), None); + assert_eq!(key.expires_at(), None); + + let mut budget_only = ffi_row(7, &pk); + budget_only.has_total_budget = true; + budget_only.total_budget = 10_000_000_000; + let key = key_with_row_limits(base(), &budget_only); + assert!(matches!(key, IdentityPublicKey::V1(_))); + assert_eq!(key.total_budget(), Some(10_000_000_000)); + assert_eq!(key.expires_at(), None); + + let mut expiry_only = ffi_row(7, &pk); + expiry_only.has_expires_at = true; + expiry_only.expires_at = 1_800_000_000_000; + let key = key_with_row_limits(base(), &expiry_only); + assert!(matches!(key, IdentityPublicKey::V1(_))); + assert_eq!(key.total_budget(), None); + assert_eq!(key.expires_at(), Some(1_800_000_000_000)); + + let mut both = ffi_row(7, &pk); + both.has_total_budget = true; + both.total_budget = 10_000_000_000; + both.has_expires_at = true; + both.expires_at = 1_800_000_000_000; + let key = key_with_row_limits(base(), &both); + assert!(matches!(key, IdentityPublicKey::V1(_))); + assert_eq!(key.total_budget(), Some(10_000_000_000)); + assert_eq!(key.expires_at(), Some(1_800_000_000_000)); + } + + /// The identity create / update transitions carry the key as an + /// `IdentityPublicKeyInCreation`; a limited row must become the V1 + /// variant there too, since the limits are part of the signable bytes. + #[test] + fn limited_row_becomes_a_v1_key_in_creation() { + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV1Getters; + use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + + let pk = [0x02u8; 33]; + let mut session = ffi_row(9, &pk); + session.security_level = 2; // SecurityLevel::HIGH + session.has_total_budget = true; + session.total_budget = 10_000_000_000; + session.has_expires_at = true; + session.expires_at = 1_800_000_000_000; + let rows = [ffi_row(0, &pk), session]; + + // SAFETY: `rows` (and the pubkey array it borrows) outlive the call. + let map = unsafe { decode_identity_pubkeys(rows.as_ptr(), rows.len()) } + .expect("limited authentication key must decode"); + + let master = IdentityPublicKeyInCreation::from(&map[&0]); + assert!(matches!(master, IdentityPublicKeyInCreation::V0(_))); + assert!(!master.has_limits()); + + let session = IdentityPublicKeyInCreation::from(&map[&9]); + assert!(matches!(session, IdentityPublicKeyInCreation::V1(_))); + assert_eq!(session.total_budget(), Some(10_000_000_000)); + assert_eq!(session.expires_at(), Some(1_800_000_000_000)); + } + #[test] fn decode_contract_bounds_accepts_none_for_authentication_encryption_and_decryption() { let pk = [0x02u8; 33]; diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 666263532c1..623ef570548 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -15,7 +15,9 @@ use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; -use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use dpp::state_transition::public_key_in_creation::accessors::{ + IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV1Getters, +}; use dpp::state_transition::StateTransition; use rs_sdk_ffi::{SignerHandle, VTableSigner}; @@ -52,6 +54,17 @@ pub struct ParsedIdentityUpdatePublicKeyFFI { pub contract_bounds_kind: u8, pub contract_bounds_id: [u8; 32], pub contract_bounds_document_type: *mut c_char, + /// Whether the key is registered with a `total_budget` (protocol + /// version 14, `IdentityPublicKeyInCreation::V1`). + pub has_total_budget: bool, + /// Credits the key may take from the identity over its lifetime, when + /// `has_total_budget`. + pub total_budget: u64, + /// Whether the key is registered with an `expires_at`. + pub has_expires_at: bool, + /// Block time in milliseconds from which the key can no longer sign, + /// when `has_expires_at`. + pub expires_at: u64, } /// Owned C representation of the inspectable parts of a parsed @@ -85,7 +98,7 @@ fn parse_identity_update_transition_bytes( > { // Tolerates Yappr's tagless framing next to standard tagged bytes — see // the shared helper for the ordering heuristic. - let state_transition = + let (state_transition, _tagged_bytes) = crate::parse_state_transition::deserialize_transition_with_flexible_framing( bytes, &[(IDENTITY_UPDATE_VARIANT_TAG, "IdentityUpdate")], @@ -188,6 +201,10 @@ pub(crate) fn project_parsed_identity_update( contract_bounds_kind, contract_bounds_id, contract_bounds_document_type, + has_total_budget: public_key.total_budget().is_some(), + total_budget: public_key.total_budget().unwrap_or_default(), + has_expires_at: public_key.expires_at().is_some(), + expires_at: public_key.expires_at().unwrap_or_default(), }); } diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index dc32a5957b6..f9bf8f2c602 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -26,6 +26,7 @@ pub mod dashpay_sync; pub mod data_contract; pub mod derivation; pub mod derive_and_persist_callbacks; +pub mod derive_connect_key; pub mod derive_identity_key_at_slot; pub mod document; pub mod dpns; @@ -109,6 +110,7 @@ pub use dashpay_sync::*; pub use data_contract::*; pub use derivation::*; pub use derive_and_persist_callbacks::*; +pub use derive_connect_key::*; pub use derive_identity_key_at_slot::*; pub use document::*; pub use dpns::*; diff --git a/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs b/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs index 2460dfaf975..13090125ffa 100644 --- a/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs +++ b/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs @@ -1,33 +1,59 @@ //! FFI parser for raw DPP state transitions handed to the wallet by a dApp -//! (DashConnect `dash-st:` links / QRs). +//! (DashConnect `dash-st:` links / QRs, DashPay Connect `sign`). //! -//! The wallet must never sign opaque bytes a web page hands it. This module -//! only reads the *intent* out of the payload so the app can show it to the -//! user; after approval the app rebuilds and signs the operation through the -//! normal wallet path (`platform_wallet_token_purchase` / -//! `platform_wallet_update_identity_with_signer`). There is deliberately no -//! "sign these transition bytes" entry point here or anywhere else in this -//! crate. +//! The wallet must never sign opaque bytes a web page hands it without +//! showing the user what they are. This module decodes any state transition +//! kind with the bounded untrusted decoder and projects a typed summary the +//! approval sheet can describe: //! -//! One umbrella parser (rather than one probe per transition kind) so the -//! app can branch on the returned `kind` discriminant instead of driving -//! control flow off "expected X, got Y" errors: DashConnect key registration -//! arrives as an `IdentityUpdateTransition`, while a dApp token purchase -//! (e.g. Yappr) arrives as a `BatchTransition` carrying a single -//! `TokenDirectPurchase` — both come through the same `dash-st:` channel and -//! are only distinguishable after deserialization. +//! - `Batch`: one row per batched transition (contract id, document type, +//! action, and for token transitions the amount and recipient), +//! - `IdentityUpdate`: every key added (purpose, level, bounds, limits) and +//! every key disabled, +//! - `IdentityCreditTransfer`: recipient and amount, +//! - `DataContractCreate` / `DataContractUpdate`: contract id and document +//! type names, +//! - everything else: the kind name only. +//! +//! Every result also carries the kind name, the owner id, whether the +//! transition is already signed, and the exact bytes that were decoded (with +//! the variant tag, so the caller can sign what it showed). Nothing is +//! refused on kind: the sheet shows what is asked and the user decides. +//! Kinds without a describer are shown as a structured dump behind an +//! advanced setting on the client side. +//! +//! This module does not sign and does not broadcast. use std::borrow::Cow; +use std::ffi::CString; +use std::os::raw::c_char; +use std::ptr; use std::slice; +use dpp::prelude::Identifier; use dpp::serialization::PlatformDeserializableUntrusted; use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; -use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransition; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionTypeGetter; +use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::token_transition_action_type::TokenTransitionActionTypeGetter; use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; -use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; +use dpp::state_transition::batch_transition::batched_transition::document_purchase_transition::v0::v0_methods::DocumentPurchaseTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_burn_transition::v0::v0_methods::TokenBurnTransitionV0Methods; +use dpp::state_transition::batch_transition::token_destroy_frozen_funds_transition::v0::v0_methods::TokenDestroyFrozenFundsTransitionV0Methods; use dpp::state_transition::batch_transition::token_direct_purchase_transition::v0::v0_methods::TokenDirectPurchaseTransitionV0Methods; +use dpp::state_transition::batch_transition::token_freeze_transition::v0::v0_methods::TokenFreezeTransitionV0Methods; +use dpp::state_transition::batch_transition::token_mint_transition::v0::v0_methods::TokenMintTransitionV0Methods; +use dpp::state_transition::batch_transition::token_transfer_transition::v0::v0_methods::TokenTransferTransitionV0Methods; +use dpp::state_transition::batch_transition::token_unfreeze_transition::v0::v0_methods::TokenUnfreezeTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::{DocumentTransition, TokenTransition}; use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::data_contract_create_transition::accessors::DataContractCreateTransitionAccessorsV0; +use dpp::state_transition::data_contract_update_transition::accessors::DataContractUpdateTransitionAccessorsV0; +use dpp::state_transition::identity_credit_transfer_transition::accessors::IdentityCreditTransferTransitionAccessorsV0; use dpp::state_transition::{StateTransition, StateTransitionOwned}; use crate::check_ptr; @@ -45,8 +71,25 @@ pub(crate) const BATCH_VARIANT_TAG: u8 = 2; pub const PARSED_STATE_TRANSITION_KIND_NONE: u8 = 0; /// `ParsedStateTransitionFFI::kind`: `identity_update` is populated. pub const PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE: u8 = 1; -/// `ParsedStateTransitionFFI::kind`: `token_direct_purchase` is populated. -pub const PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE: u8 = 2; +/// `ParsedStateTransitionFFI::kind`: `batch` is populated. +pub const PARSED_STATE_TRANSITION_KIND_BATCH: u8 = 2; +/// `ParsedStateTransitionFFI::kind`: `credit_transfer` is populated. +pub const PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER: u8 = 3; +/// `ParsedStateTransitionFFI::kind`: `data_contract` is populated and the +/// transition creates the contract. +pub const PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE: u8 = 4; +/// `ParsedStateTransitionFFI::kind`: `data_contract` is populated and the +/// transition updates the contract. +pub const PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE: u8 = 5; +/// `ParsedStateTransitionFFI::kind`: a kind this module has no describer +/// for. Only the common fields (`kind_name`, `owner_id`, `is_signed`, +/// `serialized`) are populated. +pub const PARSED_STATE_TRANSITION_KIND_OTHER: u8 = 255; + +/// `ParsedBatchedTransitionFFI::family`: a document transition. +pub const PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT: u8 = 0; +/// `ParsedBatchedTransitionFFI::family`: a token transition. +pub const PARSED_BATCHED_TRANSITION_FAMILY_TOKEN: u8 = 1; /// Variant tags tried when the payload appears to use a tagless framing. /// `IdentityUpdate` first: it is the framing Yappr has actually been observed @@ -56,67 +99,192 @@ const TAGLESS_FRAMING_CANDIDATES: &[(u8, &str)] = &[ (BATCH_VARIANT_TAG, "Batch"), ]; -/// Owned C representation of the inspectable parts of a token direct -/// purchase carried by a parsed `BatchTransition`. +/// One transition inside a parsed `BatchTransition`. /// -/// Plain old data — no heap allocations, so nothing beyond the containing -/// [`ParsedStateTransitionFFI`] needs freeing. +/// `action` is a Rust-owned NUL-terminated string naming the action as +/// `DocumentTransitionActionType` / `TokenTransitionActionType` spell it +/// (`Create`, `Replace`, `Delete`, `Transfer`, `Purchase`, `UpdatePrice`, +/// `IndexOnlyDelete`; `Burn`, `Mint`, `Transfer`, `Freeze`, `Unfreeze`, +/// `DestroyFrozenFunds`, `Claim`, `EmergencyAction`, `ConfigUpdate`, +/// `DirectPurchase`, `SetPriceForDirectPurchase`). `document_type` is set +/// for document transitions and null for token ones. Both are released by +/// [`platform_wallet_parse_state_transition_free`]. /// -/// Carries everything `platform_wallet_token_purchase` needs to rebuild the -/// purchase after user approval, plus what the user must see before -/// approving (`owner_id` — the identity that will be charged — and the -/// `token_id` the price is quoted for). +/// `has_amount` / `amount` carry the credits or tokens the transition +/// moves: a document purchase price, an update-price value, a token +/// transfer / mint / burn amount, or a direct purchase's total agreed +/// price. `has_recipient` / `recipient_id` carry the identity on the other +/// side: a document transfer's new owner, a token transfer's recipient, a +/// mint's issued-to identity, or the frozen identity of a freeze / unfreeze +/// / destroy. Both are interpreted against `action`: the sheet should say +/// "freeze the tokens of X" for a `Freeze`, not "send to X". +/// `token_contract_position` and `token_id` are set for every token +/// transition. #[repr(C)] -#[derive(Default)] -pub struct ParsedTokenDirectPurchaseFFI { - /// The identity whose credits pay for the purchase. - pub owner_id: [u8; 32], - /// The data contract defining the token. +pub struct ParsedBatchedTransitionFFI { + /// `PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT` or `_TOKEN`. + pub family: u8, + /// Data contract the transition acts within. pub data_contract_id: [u8; 32], - /// The token being bought. - pub token_id: [u8; 32], - /// Position of the token within the contract. + /// Action name, see the struct doc. Owned, NUL-terminated. + pub action: *mut c_char, + /// Document type name for document transitions; null for token ones. + pub document_type: *mut c_char, + /// Document id for document transitions; zero for token ones. + pub document_id: [u8; 32], + /// Position of the token within its contract (token transitions). pub token_contract_position: u16, - /// How many tokens the dApp asks to buy. - pub token_count: u64, - /// Credits the owner would agree to pay in total. - pub total_agreed_price: u64, + /// Token id (token transitions); zero for document ones. + pub token_id: [u8; 32], + pub has_amount: bool, + pub amount: u64, + pub has_recipient: bool, + pub recipient_id: [u8; 32], } -/// Owned C representation of one parsed state transition, discriminated by -/// `kind`. Exactly one payload field is populated; the other stays in its -/// zeroed default state. Must be released via -/// [`platform_wallet_parse_state_transition_free`] regardless of `kind` -/// (freeing a default / token-purchase value is a safe no-op). +impl Default for ParsedBatchedTransitionFFI { + fn default() -> Self { + Self { + family: PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT, + data_contract_id: [0u8; 32], + action: ptr::null_mut(), + document_type: ptr::null_mut(), + document_id: [0u8; 32], + token_contract_position: 0, + token_id: [0u8; 32], + has_amount: false, + amount: 0, + has_recipient: false, + recipient_id: [0u8; 32], + } + } +} + +/// Owned C representation of a parsed `BatchTransition`: its owner and one +/// [`ParsedBatchedTransitionFFI`] per batched transition, in order. +#[repr(C)] +pub struct ParsedBatchFFI { + pub owner_id: [u8; 32], + pub transitions: *mut ParsedBatchedTransitionFFI, + pub transitions_count: usize, +} + +impl Default for ParsedBatchFFI { + fn default() -> Self { + Self { + owner_id: [0u8; 32], + transitions: ptr::null_mut(), + transitions_count: 0, + } + } +} + +/// Owned C representation of a parsed `IdentityCreditTransferTransition`. +/// Plain old data. #[repr(C)] #[derive(Default)] +pub struct ParsedCreditTransferFFI { + pub identity_id: [u8; 32], + pub recipient_id: [u8; 32], + pub amount: u64, +} + +/// Owned C representation of the inspectable parts of a parsed +/// `DataContractCreateTransition` or `DataContractUpdateTransition`. +/// `document_type_names` is a Rust-owned array of NUL-terminated strings, +/// sorted as the contract stores them. +#[repr(C)] +pub struct ParsedDataContractFFI { + pub contract_id: [u8; 32], + pub owner_id: [u8; 32], + pub document_type_names: *mut *mut c_char, + pub document_type_names_count: usize, +} + +impl Default for ParsedDataContractFFI { + fn default() -> Self { + Self { + contract_id: [0u8; 32], + owner_id: [0u8; 32], + document_type_names: ptr::null_mut(), + document_type_names_count: 0, + } + } +} + +/// Owned C representation of one parsed state transition, discriminated by +/// `kind`. Exactly one of the kind-specific payload fields is populated +/// (none for `PARSED_STATE_TRANSITION_KIND_OTHER`); the rest stay in their +/// zeroed default state. The common fields are populated for every kind. +/// Must be released via [`platform_wallet_parse_state_transition_free`] +/// regardless of `kind`. +#[repr(C)] pub struct ParsedStateTransitionFFI { /// One of the `PARSED_STATE_TRANSITION_KIND_*` constants. pub kind: u8, + /// `StateTransition::name()` of the decoded transition, e.g. + /// `IdentityUpdate`, `DocumentsBatch([Create, TokenTransfer])`, + /// `MasternodeVote`. Owned, NUL-terminated. + pub kind_name: *mut c_char, + /// Whether the transition names an owner (every kind except the + /// asset-lock-funded and shielded ones does). + pub has_owner_id: bool, + /// The identity the transition acts for, when `has_owner_id`. + pub owner_id: [u8; 32], + /// Whether the transition already carries a non-empty signature. A + /// `sign` request must arrive unsigned. + pub is_signed: bool, + /// The bytes that were decoded, always in tagged framing (the variant + /// tag was prepended when the input arrived tagless). These, not the + /// input, are what a caller should sign after approval. + pub serialized: *mut u8, + pub serialized_len: usize, /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE`. pub identity_update: ParsedIdentityUpdateFFI, - /// Populated when - /// `kind == PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE`. - pub token_direct_purchase: ParsedTokenDirectPurchaseFFI, + /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_BATCH`. + pub batch: ParsedBatchFFI, + /// Populated when `kind == PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER`. + pub credit_transfer: ParsedCreditTransferFFI, + /// Populated when `kind` is `_DATA_CONTRACT_CREATE` or `_UPDATE`. + pub data_contract: ParsedDataContractFFI, +} + +impl Default for ParsedStateTransitionFFI { + fn default() -> Self { + Self { + kind: PARSED_STATE_TRANSITION_KIND_NONE, + kind_name: ptr::null_mut(), + has_owner_id: false, + owner_id: [0u8; 32], + is_signed: false, + serialized: ptr::null_mut(), + serialized_len: 0, + identity_update: ParsedIdentityUpdateFFI::default(), + batch: ParsedBatchFFI::default(), + credit_transfer: ParsedCreditTransferFFI::default(), + data_contract: ParsedDataContractFFI::default(), + } + } } /// Deserializes `bytes` as a `StateTransition`, tolerating both normal /// tagged DPP framing and the tagless framing Yappr sends, where the /// positional bincode enum variant tag has to be prepended first. /// -/// A leading known variant tag usually means the payload is already framed -/// as a state transition, and a tagless body needs one of the `candidates` -/// tags prepended. Neither test is conclusive — a tagless body can start -/// with a tag byte by coincidence — so the likelier framing is only tried -/// first, and the others are still tried before the payload is rejected. +/// Every framing is tried: the bytes as they are, and the bytes with each +/// of the `candidates` tags prepended. Exactly one must decode. A payload +/// that decodes under two framings is refused rather than described under +/// whichever was tried first: the caller signs what the user was shown, +/// so an ambiguous payload could otherwise be approved as one transition +/// and broadcast as another. In practice a tagless body only decodes with +/// its own tag prepended and a tagged body only decodes as-is, so the +/// happy path has exactly one hit. +/// +/// Returns the transition together with the bytes that decoded it (tagged). pub(crate) fn deserialize_transition_with_flexible_framing( bytes: &[u8], candidates: &[(u8, &str)], -) -> Result { - let leads_with_candidate_tag = bytes - .first() - .is_some_and(|first| candidates.iter().any(|(tag, _)| tag == first)); - +) -> Result<(StateTransition, Vec), PlatformWalletFFIResult> { let as_is: (Cow<'_, [u8]>, String) = (Cow::Borrowed(bytes), "as-is".to_string()); let prepended = candidates.iter().map(|(tag, name)| { let mut prefixed = Vec::with_capacity(bytes.len() + 1); @@ -127,126 +295,327 @@ pub(crate) fn deserialize_transition_with_flexible_framing( format!("{name} variant tag prepended"), ) }); + let attempts: Vec<(Cow<'_, [u8]>, String)> = std::iter::once(as_is).chain(prepended).collect(); - let mut attempts: Vec<(Cow<'_, [u8]>, String)> = Vec::with_capacity(candidates.len() + 1); - if leads_with_candidate_tag { - attempts.push(as_is); - attempts.extend(prepended); - } else { - attempts.extend(prepended); - attempts.push(as_is); - } - + let mut decoded: Vec<(StateTransition, Vec, &str)> = Vec::new(); let mut failures: Vec = Vec::with_capacity(attempts.len()); for (payload, label) in &attempts { match StateTransition::deserialize_from_bytes_untrusted(payload) { - Ok(state_transition) => return Ok(state_transition), + Ok(state_transition) => decoded.push((state_transition, payload.to_vec(), label)), Err(error) => failures.push(format!("{label}: {error}")), } } - Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorDeserialization, - format!( - "Failed to deserialize state transition in any supported framing ({})", - failures.join("; ") - ), - )) + match decoded.len() { + 1 => { + let (transition, payload, _) = decoded.remove(0); + Ok((transition, payload)) + } + 0 => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + format!( + "Failed to deserialize state transition in any supported framing ({})", + failures.join("; ") + ), + )), + _ => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + format!( + "Ambiguous state transition framing: the bytes decode under more than one \ + framing ({}); refusing to guess which one the sender meant", + decoded + .iter() + .map(|(transition, _, label)| format!("{label} as {}", transition.name())) + .collect::>() + .join(", ") + ), + )), + } } -/// Projects the single `TokenDirectPurchase` out of `batch`. -/// -/// A batch may in principle carry several transitions, but anything other -/// than exactly one `TokenDirectPurchase` is rejected: the approval sheet -/// shows the user one purchase ("buy N of token T for P credits"), so a -/// multi-transition or mixed batch would execute more than the user -/// approved. The rebuild path (`platform_wallet_token_purchase`) can also -/// only reproduce a single purchase, so a wider batch could not be signed -/// faithfully even after approval. +/// A string that cannot cross the FFI as a C string is an error rather than +/// a fallback: showing the user a different name than the transition +/// declares would let them approve something other than what they saw. /// -/// `batch_description` is `StateTransition::name()` for the whole batch — -/// it lists the kinds of every inner transition, which makes the rejection -/// messages actionable without this module enumerating every batched -/// transition variant itself. -fn project_parsed_token_direct_purchase( - batch: &BatchTransition, - batch_description: &str, -) -> Result { - let transitions_len = batch.transitions_len(); - if transitions_len != 1 { - return Err(PlatformWalletFFIResult::err( +/// Returns the owning [`CString`]; callers `into_raw()` it only once every +/// string of the row they are building has converted, so a failure partway +/// through drops what was built instead of leaking a raw pointer. +fn owned_c_string(value: &str, what: &str) -> Result { + CString::new(value).map_err(|error| { + PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "Refusing to parse a batch of {transitions_len} transitions as a token \ - purchase — the user can only approve exactly one TokenDirectPurchase \ - ({batch_description})" - ), - )); + format!("{what} cannot be represented as a C string: {error}"), + ) + }) +} + +unsafe fn free_c_string(ptr: &mut *mut c_char) { + if !ptr.is_null() { + drop(CString::from_raw(*ptr)); + *ptr = ptr::null_mut(); } +} - let Some(BatchedTransitionRef::Token(TokenTransition::DirectPurchase(purchase))) = - batch.first_transition() - else { - return Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "Expected the batch to carry a single TokenDirectPurchase transition, \ - got {batch_description}" - ), - )); +fn project_document_transition( + transition: &DocumentTransition, +) -> Result { + let (amount, recipient) = match transition { + DocumentTransition::Transfer(t) => (None, Some(t.recipient_owner_id())), + DocumentTransition::Purchase(t) => (Some(t.price()), None), + DocumentTransition::UpdatePrice(t) => (Some(t.price()), None), + DocumentTransition::Create(_) + | DocumentTransition::Replace(_) + | DocumentTransition::Delete(_) + | DocumentTransition::IndexOnlyDelete(_) => (None, None), }; + let action = owned_c_string( + &format!("{:?}", transition.action_type()), + "Batched document transition action", + )?; + let document_type = owned_c_string( + transition.document_type_name(), + "Batched document transition document type", + )?; - let base = purchase.base(); + Ok(ParsedBatchedTransitionFFI { + family: PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT, + data_contract_id: transition.data_contract_id().to_buffer(), + action: action.into_raw(), + document_type: document_type.into_raw(), + document_id: transition.get_id().to_buffer(), + has_amount: amount.is_some(), + amount: amount.unwrap_or_default(), + has_recipient: recipient.is_some(), + recipient_id: recipient.map(|id| id.to_buffer()).unwrap_or_default(), + ..ParsedBatchedTransitionFFI::default() + }) +} - // DirectPurchase is not group-gated and the rebuild path submits it - // without group info, so a payload that smuggles group info in would be - // approved as one thing and signed as another. Reject it instead. - if base.using_group_info().is_some() { - return Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "TokenDirectPurchase carries group-action info, which the wallet's \ - rebuild-and-sign purchase path does not support" - .to_string(), - )); +fn project_token_transition( + transition: &TokenTransition, +) -> Result { + let (amount, recipient): (Option, Option) = match transition { + TokenTransition::Transfer(t) => (Some(t.amount()), Some(t.recipient_id())), + TokenTransition::Mint(t) => (Some(t.amount()), t.issued_to_identity_id()), + TokenTransition::Burn(t) => (Some(t.burn_amount()), None), + TokenTransition::Freeze(t) => (None, Some(t.frozen_identity_id())), + TokenTransition::Unfreeze(t) => (None, Some(t.frozen_identity_id())), + TokenTransition::DestroyFrozenFunds(t) => (None, Some(t.frozen_identity_id())), + TokenTransition::DirectPurchase(t) => (Some(t.total_agreed_price()), None), + TokenTransition::Claim(_) + | TokenTransition::EmergencyAction(_) + | TokenTransition::ConfigUpdate(_) + | TokenTransition::SetPriceForDirectPurchase(_) => (None, None), + }; + let action = owned_c_string( + &transition.action_type().to_string(), + "Batched token transition action", + )?; + let base = transition.base(); + + Ok(ParsedBatchedTransitionFFI { + family: PARSED_BATCHED_TRANSITION_FAMILY_TOKEN, + data_contract_id: transition.data_contract_id().to_buffer(), + action: action.into_raw(), + document_type: ptr::null_mut(), + token_contract_position: base.token_contract_position(), + token_id: transition.token_id().to_buffer(), + has_amount: amount.is_some(), + amount: amount.unwrap_or_default(), + has_recipient: recipient.is_some(), + recipient_id: recipient.map(|id| id.to_buffer()).unwrap_or_default(), + ..ParsedBatchedTransitionFFI::default() + }) +} + +unsafe fn free_batched_transitions(rows: &mut [ParsedBatchedTransitionFFI]) { + for row in rows.iter_mut() { + free_c_string(&mut row.action); + free_c_string(&mut row.document_type); + } +} + +fn project_parsed_batch( + batch: &BatchTransition, +) -> Result { + let mut rows: Vec = Vec::with_capacity(batch.transitions_len()); + for transition in batch.transitions_iter() { + let projected = match transition { + BatchedTransitionRef::Document(document) => project_document_transition(document), + BatchedTransitionRef::Token(token) => project_token_transition(token), + }; + match projected { + Ok(row) => rows.push(row), + Err(error) => { + // The caller never receives this struct, so nothing else will + // free the rows projected so far. + unsafe { free_batched_transitions(&mut rows) }; + return Err(error); + } + } } - Ok(ParsedTokenDirectPurchaseFFI { + let transitions_count = rows.len(); + let transitions = if transitions_count == 0 { + ptr::null_mut() + } else { + Box::into_raw(rows.into_boxed_slice()) as *mut ParsedBatchedTransitionFFI + }; + + Ok(ParsedBatchFFI { owner_id: batch.owner_id().to_buffer(), - data_contract_id: base.data_contract_id().to_buffer(), - token_id: base.token_id().to_buffer(), - token_contract_position: base.token_contract_position(), - token_count: purchase.token_count(), - total_agreed_price: purchase.total_agreed_price(), + transitions, + transitions_count, }) } +unsafe fn free_parsed_batch(batch: &mut ParsedBatchFFI) { + if !batch.transitions.is_null() && batch.transitions_count > 0 { + let rows = slice::from_raw_parts_mut(batch.transitions, batch.transitions_count); + free_batched_transitions(rows); + drop(Box::from_raw(rows as *mut [ParsedBatchedTransitionFFI])); + } + *batch = ParsedBatchFFI::default(); +} + +fn project_parsed_data_contract( + contract: &dpp::data_contract::serialized_version::DataContractInSerializationFormat, +) -> Result { + // `Vec` owns every name until all of them have converted, so a + // failure partway through frees what was built with no manual cleanup. + let mut names: Vec = Vec::with_capacity(contract.document_schemas().len()); + for name in contract.document_schemas().keys() { + names.push(owned_c_string(name, "Data contract document type name")?); + } + + let document_type_names_count = names.len(); + let document_type_names = if document_type_names_count == 0 { + ptr::null_mut() + } else { + let raw: Vec<*mut c_char> = names.into_iter().map(CString::into_raw).collect(); + Box::into_raw(raw.into_boxed_slice()) as *mut *mut c_char + }; + + Ok(ParsedDataContractFFI { + contract_id: contract.id().to_buffer(), + owner_id: contract.owner_id().to_buffer(), + document_type_names, + document_type_names_count, + }) +} + +unsafe fn free_parsed_data_contract(contract: &mut ParsedDataContractFFI) { + if !contract.document_type_names.is_null() && contract.document_type_names_count > 0 { + let names = slice::from_raw_parts_mut( + contract.document_type_names, + contract.document_type_names_count, + ); + for name in names.iter_mut() { + free_c_string(name); + } + drop(Box::from_raw(names as *mut [*mut c_char])); + } + *contract = ParsedDataContractFFI::default(); +} + +/// Projects a decoded transition into the C struct. Everything allocated +/// is owned by `out` on success; on failure nothing is left allocated. +fn project_parsed_state_transition( + transition: &StateTransition, + serialized: Vec, +) -> Result { + let mut out = ParsedStateTransitionFFI::default(); + + let payload = match transition { + StateTransition::IdentityUpdate(identity_update) => { + project_parsed_identity_update(identity_update).map(|parsed| { + out.identity_update = parsed; + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE + }) + } + StateTransition::Batch(batch) => project_parsed_batch(batch).map(|parsed| { + out.batch = parsed; + PARSED_STATE_TRANSITION_KIND_BATCH + }), + StateTransition::IdentityCreditTransfer(transfer) => { + out.credit_transfer = ParsedCreditTransferFFI { + identity_id: transfer.identity_id().to_buffer(), + recipient_id: transfer.recipient_id().to_buffer(), + amount: transfer.amount(), + }; + Ok(PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER) + } + StateTransition::DataContractCreate(create) => { + project_parsed_data_contract(create.data_contract()).map(|parsed| { + out.data_contract = parsed; + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE + }) + } + StateTransition::DataContractUpdate(update) => { + project_parsed_data_contract(update.data_contract()).map(|parsed| { + out.data_contract = parsed; + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE + }) + } + _ => Ok(PARSED_STATE_TRANSITION_KIND_OTHER), + }; + let kind = match payload { + Ok(kind) => kind, + Err(error) => { + // Nothing kind-specific was stored on a failure, so only the + // default (empty) payloads are released here. + unsafe { free_parsed_state_transition_payloads(&mut out) }; + return Err(error); + } + }; + + let kind_name = match owned_c_string(&transition.name(), "State transition kind name") { + Ok(name) => name.into_raw(), + Err(error) => { + unsafe { free_parsed_state_transition_payloads(&mut out) }; + return Err(error); + } + }; + + let owner_id = transition.owner_id(); + let serialized_len = serialized.len(); + let serialized_ptr = Box::into_raw(serialized.into_boxed_slice()) as *mut u8; + + out.kind = kind; + out.kind_name = kind_name; + out.has_owner_id = owner_id.is_some(); + out.owner_id = owner_id.map(|id| id.to_buffer()).unwrap_or_default(); + out.is_signed = transition.signature().is_some_and(|sig| !sig.is_empty()); + out.serialized = serialized_ptr; + out.serialized_len = serialized_len; + Ok(out) +} + +unsafe fn free_parsed_state_transition_payloads(parsed: &mut ParsedStateTransitionFFI) { + platform_wallet_parse_identity_update_transition_free(&mut parsed.identity_update); + free_parsed_batch(&mut parsed.batch); + free_parsed_data_contract(&mut parsed.data_contract); + parsed.credit_transfer = ParsedCreditTransferFFI::default(); +} + /// Deserializes a raw DPP state transition (as carried by a DashConnect -/// `dash-st:` link / QR) into its inspectable parts, reporting which -/// supported kind it found in `out.kind` so the caller can branch without -/// probing kind-specific parsers and branching on their errors. -/// -/// Supported kinds: -/// - `IdentityUpdateTransition` (DashConnect key registration) → -/// `PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE`, `identity_update` -/// populated exactly as by -/// `platform_wallet_parse_identity_update_transition`. -/// - `BatchTransition` carrying exactly one `TokenDirectPurchase` (a dApp -/// token purchase) → -/// `PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE`, -/// `token_direct_purchase` populated. +/// `dash-st:` link / QR or a DashPay Connect `sign` request) into its +/// inspectable parts, reporting which kind it found in `out.kind` so the +/// caller can branch without probing kind-specific parsers. /// -/// Every other transition kind — including batches that are empty, carry -/// several transitions, or carry anything other than a direct purchase — is -/// rejected with `ErrorInvalidParameter` naming what was found. +/// Every kind decodes. `IdentityUpdate`, `Batch`, `IdentityCreditTransfer` +/// and the two data contract transitions get a typed summary (see the +/// module doc); every other kind is reported as +/// `PARSED_STATE_TRANSITION_KIND_OTHER` with the common fields only +/// (`kind_name`, `owner_id`, `is_signed`, `serialized`), so the client can +/// show a structured dump rather than refuse. /// /// Accepts both normal tagged DPP state-transition bytes and Yappr's /// tagless framing, where the positional bincode enum variant tag has to be -/// prepended before deserialization. +/// prepended before deserialization; `out.serialized` always holds the +/// tagged bytes that actually decoded. /// -/// Does NOT sign and does NOT broadcast — the caller shows the parsed -/// intent to the user and rebuilds the operation through the normal signing -/// path (`platform_wallet_token_purchase` / -/// `platform_wallet_update_identity_with_signer`). +/// Does NOT sign and does NOT broadcast. #[no_mangle] pub unsafe extern "C" fn platform_wallet_parse_state_transition( transition_bytes: *const u8, @@ -259,41 +628,18 @@ pub unsafe extern "C" fn platform_wallet_parse_state_transition( *out = ParsedStateTransitionFFI::default(); let bytes = slice::from_raw_parts(transition_bytes, transition_len); - let transition = unwrap_result_or_return!(deserialize_transition_with_flexible_framing( - bytes, - TAGLESS_FRAMING_CANDIDATES, - )); + let (transition, serialized) = unwrap_result_or_return!( + deserialize_transition_with_flexible_framing(bytes, TAGLESS_FRAMING_CANDIDATES) + ); - match &transition { - StateTransition::IdentityUpdate(identity_update) => { - (*out).identity_update = - unwrap_result_or_return!(project_parsed_identity_update(identity_update)); - (*out).kind = PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE; - } - StateTransition::Batch(batch) => { - (*out).token_direct_purchase = unwrap_result_or_return!( - project_parsed_token_direct_purchase(batch, &transition.name()) - ); - (*out).kind = PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE; - } - other => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "Unsupported state transition kind for dApp intent parsing: {}", - other.name() - ), - ); - } - } + *out = unwrap_result_or_return!(project_parsed_state_transition(&transition, serialized)); PlatformWalletFFIResult::ok() } /// Frees a parsed transition previously returned by /// [`platform_wallet_parse_state_transition`]. Safe to call for any `kind`, -/// including the zeroed default — only the identity-update payload owns -/// heap allocations. +/// including the zeroed default, and idempotent. #[no_mangle] pub unsafe extern "C" fn platform_wallet_parse_state_transition_free( out: *mut ParsedStateTransitionFFI, @@ -303,36 +649,64 @@ pub unsafe extern "C" fn platform_wallet_parse_state_transition_free( } let parsed = &mut *out; - platform_wallet_parse_identity_update_transition_free(&mut parsed.identity_update); + free_parsed_state_transition_payloads(parsed); + free_c_string(&mut parsed.kind_name); + if !parsed.serialized.is_null() && parsed.serialized_len > 0 { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + parsed.serialized, + parsed.serialized_len, + ))); + } *parsed = ParsedStateTransitionFFI::default(); } #[cfg(test)] mod tests { use super::*; - use dpp::group::GroupStateTransitionInfo; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::serialized_version::DataContractInSerializationFormat; + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::{KeyType, Purpose, SecurityLevel}; - use dpp::platform_value::BinaryData; + use dpp::platform_value::{platform_value, BinaryData}; use dpp::prelude::Identifier; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; + use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use dpp::state_transition::batch_transition::document_create_transition::v0::DocumentCreateTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::DocumentTransferTransition; use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; - use dpp::state_transition::batch_transition::token_burn_transition::v0::TokenBurnTransitionV0; use dpp::state_transition::batch_transition::token_direct_purchase_transition::v0::TokenDirectPurchaseTransitionV0; + use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; use dpp::state_transition::batch_transition::{ - BatchTransitionV1, TokenBurnTransition, TokenDirectPurchaseTransition, + BatchTransitionV1, DocumentCreateTransition, TokenDirectPurchaseTransition, + TokenTransferTransition, }; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; + use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransitionV0; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::TryFromPlatformVersioned; use dpp::state_transition::identity_credit_transfer_transition::v0::IdentityCreditTransferTransitionV0; use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use dpp::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + use std::ffi::CStr; + + const OWNER: [u8; 32] = [0x21; 32]; + const CONTRACT: [u8; 32] = [0x42; 32]; + const TOKEN: [u8; 32] = [0x77; 32]; + const RECIPIENT: [u8; 32] = [0x22; 32]; - fn purchase_base() -> TokenBaseTransition { + fn token_base() -> TokenBaseTransition { TokenBaseTransition::V0(TokenBaseTransitionV0 { identity_contract_nonce: 4, token_contract_position: 3, - data_contract_id: Identifier::from([0x42; 32]), - token_id: Identifier::from([0x77; 32]), + data_contract_id: Identifier::from(CONTRACT), + token_id: Identifier::from(TOKEN), using_group_info: None, }) } @@ -340,20 +714,63 @@ mod tests { fn direct_purchase() -> BatchedTransition { BatchedTransition::Token(TokenTransition::DirectPurchase( TokenDirectPurchaseTransition::V0(TokenDirectPurchaseTransitionV0 { - base: purchase_base(), + base: token_base(), token_count: 100, total_agreed_price: 100_000_000, }), )) } - fn batch_transition_bytes(transitions: Vec) -> Vec { + fn token_transfer() -> BatchedTransition { + BatchedTransition::Token(TokenTransition::Transfer(TokenTransferTransition::V0( + TokenTransferTransitionV0 { + base: token_base(), + amount: 250, + recipient_id: Identifier::from(RECIPIENT), + public_note: None, + shared_encrypted_note: None, + private_encrypted_note: None, + }, + ))) + } + + fn document_base(document_type_name: &str) -> DocumentBaseTransition { + DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([0x0D; 32]), + identity_contract_nonce: 1, + document_type_name: document_type_name.to_string(), + data_contract_id: Identifier::from(CONTRACT), + }) + } + + fn document_create(document_type_name: &str) -> BatchedTransition { + BatchedTransition::Document(DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base: document_base(document_type_name), + entropy: [0xEE; 32], + data: BTreeMap::from([("message".to_string(), platform_value!("hi"))]), + prefunded_voting_balance: None, + }, + ))) + } + + fn document_transfer() -> BatchedTransition { + BatchedTransition::Document(DocumentTransition::Transfer( + DocumentTransferTransition::V0(DocumentTransferTransitionV0 { + base: document_base("profile"), + revision: 2, + recipient_owner_id: Identifier::from(RECIPIENT), + }), + )) + } + + fn batch_transition_bytes(transitions: Vec, signature: Vec) -> Vec { StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { - owner_id: Identifier::from([0x21; 32]), + owner_id: Identifier::from(OWNER), transitions, user_fee_increase: 1, signature_public_key_id: 2, - signature: BinaryData::new(vec![0x88; 65]), + signature: BinaryData::new(signature), })) .serialize_to_bytes() .expect("fixture batch serializes") @@ -367,17 +784,36 @@ mod tests { identity_id: Identifier::from([0x11; 32]), revision: 7, nonce: 9, - add_public_keys: vec![IdentityPublicKeyInCreationV0 { - id: 17, - key_type: KeyType::ECDSA_SECP256K1, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - read_only: false, - data: BinaryData::new(vec![0x02; 33]), - signature: BinaryData::new(vec![0xaa; 65]), - contract_bounds: None, - } - .into()], + add_public_keys: vec![ + IdentityPublicKeyInCreationV0 { + id: 17, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + read_only: false, + data: BinaryData::new(vec![0x02; 33]), + signature: BinaryData::new(vec![0xaa; 65]), + contract_bounds: None, + } + .into(), + // A DashPay Connect session key: HIGH auth key bound to a + // contract group with a budget and an expiry. + IdentityPublicKeyInCreationV1 { + id: 18, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + read_only: false, + data: BinaryData::new(vec![0x03; 33]), + signature: BinaryData::new(vec![0xbb; 65]), + contract_bounds: Some(ContractBounds::ContractGroup { + id: Identifier::from([0x66; 32]), + }), + total_budget: Some(10_000_000_000), + expires_at: Some(1_800_000_000_000), + } + .into(), + ], disable_public_keys: vec![4, 8], user_fee_increase: 2, } @@ -387,6 +823,78 @@ mod tests { .expect("fixture identity update serializes") } + fn credit_transfer_bytes() -> Vec { + StateTransition::IdentityCreditTransfer( + IdentityCreditTransferTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from(RECIPIENT), + amount: 1_000, + nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture credit transfer serializes") + } + + /// The rs-dpp fixture contract (owner `OWNER`), in the wire form a + /// create / update transition carries. Its id and document type names + /// are read back from the fixture rather than hard-coded so the test + /// follows the fixture if it changes. + fn contract_format() -> (DataContractInSerializationFormat, Identifier, Vec) { + let created = get_data_contract_fixture( + Some(Identifier::from(OWNER)), + 1, + PlatformVersion::latest().protocol_version, + ); + let contract = created.data_contract(); + let id = contract.id(); + let names: Vec = contract.document_types().keys().cloned().collect(); + let format = DataContractInSerializationFormat::try_from_platform_versioned( + contract, + PlatformVersion::latest(), + ) + .expect("fixture contract converts to its serialization format"); + (format, id, names) + } + + fn data_contract_create_bytes() -> (Vec, Identifier, Vec) { + let (format, id, names) = contract_format(); + let bytes = StateTransition::DataContractCreate( + DataContractCreateTransitionV0 { + data_contract: format, + identity_nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture contract create serializes"); + (bytes, id, names) + } + + fn data_contract_update_bytes() -> (Vec, Identifier, Vec) { + let (format, id, names) = contract_format(); + let bytes = StateTransition::DataContractUpdate( + DataContractUpdateTransitionV0 { + identity_contract_nonce: 2, + data_contract: format, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture contract update serializes"); + (bytes, id, names) + } + fn parse(bytes: &[u8]) -> (PlatformWalletFFIResult, ParsedStateTransitionFFI) { let mut out = ParsedStateTransitionFFI::default(); let result = unsafe { @@ -395,31 +903,109 @@ mod tests { (result, out) } + unsafe fn c_str(ptr: *const c_char) -> String { + CStr::from_ptr(ptr).to_str().expect("utf8").to_string() + } + + unsafe fn batched_rows(out: &ParsedStateTransitionFFI) -> &[ParsedBatchedTransitionFFI] { + slice::from_raw_parts(out.batch.transitions, out.batch.transitions_count) + } + + unsafe fn serialized(out: &ParsedStateTransitionFFI) -> &[u8] { + slice::from_raw_parts(out.serialized, out.serialized_len) + } + #[test] - fn parses_a_tagged_token_direct_purchase_batch() { - let bytes = batch_transition_bytes(vec![direct_purchase()]); + fn parses_a_mixed_batch_with_one_row_per_transition() { + let bytes = batch_transition_bytes( + vec![ + document_create("post"), + document_transfer(), + token_transfer(), + direct_purchase(), + ], + vec![0x88; 65], + ); let (result, mut out) = parse(&bytes); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE); - assert_eq!(out.token_direct_purchase.owner_id, [0x21; 32]); - assert_eq!(out.token_direct_purchase.data_contract_id, [0x42; 32]); - assert_eq!(out.token_direct_purchase.token_id, [0x77; 32]); - assert_eq!(out.token_direct_purchase.token_contract_position, 3); - assert_eq!(out.token_direct_purchase.token_count, 100); - assert_eq!(out.token_direct_purchase.total_agreed_price, 100_000_000); - // The unused payload stays in its default state. + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_BATCH); + assert_eq!( + unsafe { c_str(out.kind_name) }, + "DocumentsBatch([Create, Transfer, TokenTransfer, TokenDirectPurchase])" + ); + assert!(out.has_owner_id); + assert_eq!(out.owner_id, OWNER); + assert!(out.is_signed); + assert_eq!(unsafe { serialized(&out) }, bytes.as_slice()); + assert_eq!(out.batch.owner_id, OWNER); + assert_eq!(out.batch.transitions_count, 4); + + let rows = unsafe { batched_rows(&out) }; + + let create = &rows[0]; + assert_eq!(create.family, PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT); + assert_eq!(unsafe { c_str(create.action) }, "Create"); + assert_eq!(unsafe { c_str(create.document_type) }, "post"); + assert_eq!(create.data_contract_id, CONTRACT); + assert_eq!(create.document_id, [0x0D; 32]); + assert!(!create.has_amount); + assert!(!create.has_recipient); + + let transfer = &rows[1]; + assert_eq!(unsafe { c_str(transfer.action) }, "Transfer"); + assert_eq!(unsafe { c_str(transfer.document_type) }, "profile"); + assert!(transfer.has_recipient); + assert_eq!(transfer.recipient_id, RECIPIENT); + + let token_transfer = &rows[2]; + assert_eq!( + token_transfer.family, + PARSED_BATCHED_TRANSITION_FAMILY_TOKEN + ); + assert_eq!(unsafe { c_str(token_transfer.action) }, "Transfer"); + assert!(token_transfer.document_type.is_null()); + assert_eq!(token_transfer.data_contract_id, CONTRACT); + assert_eq!(token_transfer.token_id, TOKEN); + assert_eq!(token_transfer.token_contract_position, 3); + assert!(token_transfer.has_amount); + assert_eq!(token_transfer.amount, 250); + assert!(token_transfer.has_recipient); + assert_eq!(token_transfer.recipient_id, RECIPIENT); + + let purchase = &rows[3]; + assert_eq!(unsafe { c_str(purchase.action) }, "DirectPurchase"); + assert!(purchase.has_amount); + assert_eq!(purchase.amount, 100_000_000); + assert!(!purchase.has_recipient); + + // The unused payloads stay in their default state. assert!(out.identity_update.add_public_keys.is_null()); - assert_eq!(out.identity_update.add_public_keys_count, 0); + assert_eq!(out.credit_transfer.amount, 0); + assert!(out.data_contract.document_type_names.is_null()); unsafe { platform_wallet_parse_state_transition_free(&mut out) }; assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); - assert_eq!(out.token_direct_purchase.token_count, 0); + assert!(out.kind_name.is_null()); + assert!(out.serialized.is_null()); + assert!(out.batch.transitions.is_null()); + assert_eq!(out.batch.transitions_count, 0); + } + + #[test] + fn an_empty_batch_and_an_unsigned_batch_both_decode() { + let (result, mut out) = parse(&batch_transition_bytes(vec![], vec![])); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_BATCH); + assert_eq!(out.batch.transitions_count, 0); + assert!(out.batch.transitions.is_null()); + assert!(!out.is_signed); + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; } #[test] - fn parses_a_tagless_token_purchase_by_prepending_the_batch_tag() { - let tagged = batch_transition_bytes(vec![direct_purchase()]); + fn parses_a_tagless_batch_by_prepending_the_batch_tag_and_reports_tagged_bytes() { + let tagged = batch_transition_bytes(vec![direct_purchase()], vec![0x88; 65]); assert_eq!( tagged[0], BATCH_VARIANT_TAG, "StateTransition::Batch variant tag drifted" @@ -429,22 +1015,26 @@ mod tests { let (result, mut out) = parse(&tagless); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE); - assert_eq!(out.token_direct_purchase.token_count, 100); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_BATCH); + assert_eq!(unsafe { serialized(&out) }, tagged.as_slice()); unsafe { platform_wallet_parse_state_transition_free(&mut out) }; } #[test] - fn parses_an_identity_update_and_reports_its_kind() { + fn parses_an_identity_update_with_key_limits_and_bounds() { let bytes = identity_update_transition_bytes(); let (result, mut out) = parse(&bytes); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE); + assert_eq!(unsafe { c_str(out.kind_name) }, "IdentityUpdate"); + assert_eq!(out.owner_id, [0x11; 32]); + assert!(out.is_signed); assert_eq!(out.identity_update.identity_id, [0x11; 32]); - assert_eq!(out.identity_update.add_public_keys_count, 1); + assert_eq!(out.identity_update.add_public_keys_count, 2); assert_eq!(out.identity_update.disable_public_key_ids_count, 2); + let keys = unsafe { slice::from_raw_parts( out.identity_update.add_public_keys, @@ -452,122 +1042,297 @@ mod tests { ) }; assert_eq!(keys[0].key_id, 17); - // The unused payload stays in its default state. - assert_eq!(out.token_direct_purchase.token_count, 0); + assert!(!keys[0].has_total_budget); + assert!(!keys[0].has_expires_at); + + assert_eq!(keys[1].key_id, 18); + assert_eq!(keys[1].purpose, Purpose::AUTHENTICATION as u8); + assert_eq!(keys[1].security_level, SecurityLevel::HIGH as u8); + assert_eq!(keys[1].contract_bounds_kind, 3); + assert_eq!(keys[1].contract_bounds_id, [0x66; 32]); + assert!(keys[1].has_total_budget); + assert_eq!(keys[1].total_budget, 10_000_000_000); + assert!(keys[1].has_expires_at); + assert_eq!(keys[1].expires_at, 1_800_000_000_000); unsafe { platform_wallet_parse_state_transition_free(&mut out) }; assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); assert!(out.identity_update.add_public_keys.is_null()); - assert_eq!(out.identity_update.add_public_keys_count, 0); } #[test] - fn rejects_a_batch_with_more_than_one_transition() { - let bytes = batch_transition_bytes(vec![direct_purchase(), direct_purchase()]); - let (result, out) = parse(&bytes); + fn parses_a_credit_transfer() { + let bytes = credit_transfer_bytes(); + let (result, mut out) = parse(&bytes); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter - ); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER); + assert_eq!(unsafe { c_str(out.kind_name) }, "IdentityCreditTransfer"); + assert_eq!(out.owner_id, [0x11; 32]); + assert!(!out.is_signed); + assert_eq!(out.credit_transfer.identity_id, [0x11; 32]); + assert_eq!(out.credit_transfer.recipient_id, RECIPIENT); + assert_eq!(out.credit_transfer.amount, 1_000); + + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + assert_eq!(out.credit_transfer.amount, 0); } #[test] - fn rejects_an_empty_batch() { - let bytes = batch_transition_bytes(vec![]); - let (result, out) = parse(&bytes); + fn parses_data_contract_create_and_update_with_document_type_names() { + for (fixture, kind, name) in [ + ( + data_contract_create_bytes(), + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE, + "DataContractCreate", + ), + ( + data_contract_update_bytes(), + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE, + "DataContractUpdate", + ), + ] { + let (bytes, contract_id, expected_names) = fixture; + assert!( + !expected_names.is_empty(), + "fixture contract has document types" + ); + let (result, mut out) = parse(&bytes); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter - ); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, kind); + assert_eq!(unsafe { c_str(out.kind_name) }, name); + assert_eq!(out.owner_id, OWNER); + assert_eq!(out.data_contract.contract_id, contract_id.to_buffer()); + assert_eq!(out.data_contract.owner_id, OWNER); + assert_eq!( + out.data_contract.document_type_names_count, + expected_names.len() + ); + let names: Vec = unsafe { + slice::from_raw_parts( + out.data_contract.document_type_names, + out.data_contract.document_type_names_count, + ) + .iter() + .map(|name| c_str(*name)) + .collect() + }; + assert_eq!(names, expected_names); + + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + assert!(out.data_contract.document_type_names.is_null()); + assert_eq!(out.data_contract.document_type_names_count, 0); + } } #[test] - fn rejects_a_batch_whose_transition_is_not_a_direct_purchase() { - let bytes = batch_transition_bytes(vec![BatchedTransition::Token(TokenTransition::Burn( - TokenBurnTransition::V0(TokenBurnTransitionV0 { - base: purchase_base(), - burn_amount: 5, - public_note: None, - }), - ))]); - let (result, out) = parse(&bytes); + fn reports_kinds_without_a_describer_as_other_with_the_common_fields() { + use dpp::state_transition::masternode_vote_transition::v0::MasternodeVoteTransitionV0; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use dpp::voting::vote_polls::VotePoll; + use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; + use dpp::voting::votes::resource_vote::ResourceVote; + use dpp::voting::votes::Vote; + let bytes = StateTransition::MasternodeVote( + MasternodeVoteTransitionV0 { + pro_tx_hash: Identifier::from([0x33; 32]), + voter_identity_id: Identifier::from([0x11; 32]), + vote: Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll { + contract_id: Identifier::from(CONTRACT), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![], + }, + ), + resource_vote_choice: ResourceVoteChoice::Abstain, + })), + nonce: 1, + signature_public_key_id: 0, + signature: BinaryData::new(vec![0x99; 96]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture masternode vote serializes"); + + let (result, mut out) = parse(&bytes); + + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_OTHER); + assert_eq!(unsafe { c_str(out.kind_name) }, "MasternodeVote"); + assert!(out.has_owner_id); + assert!(out.is_signed); + assert_eq!(unsafe { serialized(&out) }, bytes.as_slice()); + + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + assert!(out.kind_name.is_null()); + } + + /// The serialized fixtures the Swift (`ParseStateTransitionTests`) and + /// Kotlin (`StateTransitionParserTest`) suites decode through the same + /// FFI. Pinned as hex so a change to the fixtures or to DPP's wire + /// format is caught here first and the client vectors are updated + /// together. + #[test] + fn fixture_bytes_are_pinned_for_the_client_suites() { + let (contract_create, _, _) = data_contract_create_bytes(); + let (contract_update, _, _) = data_contract_update_bytes(); + let actual = [ + hex::encode(identity_update_transition_bytes()), + hex::encode(batch_transition_bytes( + vec![ + document_create("post"), + document_transfer(), + token_transfer(), + direct_purchase(), + ], + vec![], + )), + hex::encode(credit_transfer_bytes()), + hex::encode(contract_create), + hex::encode(contract_update), + ]; + let expected = [ + FIXTURE_IDENTITY_UPDATE_HEX, + FIXTURE_MIXED_BATCH_HEX, + FIXTURE_CREDIT_TRANSFER_HEX, + FIXTURE_DATA_CONTRACT_CREATE_HEX, + FIXTURE_DATA_CONTRACT_UPDATE_HEX, + ] + .map(|hex| hex.trim().to_string()); assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter + actual, expected, + "fixture bytes drifted; update the Swift and Kotlin vectors with these" ); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); } + /// The canonical copies live with the Swift tests (`swift test` copies + /// the `Fixtures` directory into the test bundle); the Kotlin test reads + /// them through a relative path. Referencing them from here means the + /// three suites can never disagree about the bytes. Each file is one + /// hex line plus a trailing newline. + macro_rules! client_fixture { + ($name:literal) => { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/", + $name, + ".hex" + )) + }; + } + const FIXTURE_IDENTITY_UPDATE_HEX: &str = client_fixture!("identity_update"); + const FIXTURE_MIXED_BATCH_HEX: &str = client_fixture!("mixed_batch"); + const FIXTURE_CREDIT_TRANSFER_HEX: &str = client_fixture!("credit_transfer"); + const FIXTURE_DATA_CONTRACT_CREATE_HEX: &str = client_fixture!("data_contract_create"); + const FIXTURE_DATA_CONTRACT_UPDATE_HEX: &str = client_fixture!("data_contract_update"); + + /// Tagged bytes of every described kind decode as themselves: the + /// tagless candidates are also tried, and none may happen to decode as + /// well, or the parse would be refused as ambiguous. This is the + /// guarantee the approval sheet rests on: what is described is the + /// transition the bytes carry. #[test] - fn rejects_a_group_gated_direct_purchase() { - let bytes = batch_transition_bytes(vec![BatchedTransition::Token( - TokenTransition::DirectPurchase(TokenDirectPurchaseTransition::V0( - TokenDirectPurchaseTransitionV0 { - base: TokenBaseTransition::V0(TokenBaseTransitionV0 { - identity_contract_nonce: 4, - token_contract_position: 3, - data_contract_id: Identifier::from([0x42; 32]), - token_id: Identifier::from([0x77; 32]), - using_group_info: Some(GroupStateTransitionInfo { - group_contract_position: 1, - action_id: Identifier::from([0x55; 32]), - action_is_proposer: true, - }), - }), - token_count: 100, - total_agreed_price: 100_000_000, - }, - )), - )]); - let (result, out) = parse(&bytes); + fn tagged_bytes_of_every_kind_decode_unambiguously_as_their_own_kind() { + let (contract_create, _, _) = data_contract_create_bytes(); + let (contract_update, _, _) = data_contract_update_bytes(); + let fixtures = [ + ( + identity_update_transition_bytes(), + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE, + ), + ( + batch_transition_bytes(vec![document_create("post"), token_transfer()], vec![]), + PARSED_STATE_TRANSITION_KIND_BATCH, + ), + ( + credit_transfer_bytes(), + PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER, + ), + ( + contract_create, + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE, + ), + ( + contract_update, + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE, + ), + ]; + for (bytes, expected_kind) in fixtures { + let (result, mut out) = parse(&bytes); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, expected_kind); + assert_eq!(unsafe { serialized(&out) }, bytes.as_slice()); + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + } + } + /// A payload that decodes under two framings is refused. Built by + /// hand: a tagless identity update whose first body byte happens to be + /// a valid tag would be the real-world case; here the ambiguity is + /// forced by asking the helper to try a candidate that reproduces the + /// as-is bytes. + #[test] + fn ambiguous_framing_is_refused() { + let tagged = credit_transfer_bytes(); + let tagless = tagged[1..].to_vec(); + // Candidate `7` (IdentityCreditTransfer's tag) makes the tagless body + // decode; asking for the same tag twice means two framings decode. + let result = deserialize_transition_with_flexible_framing( + &tagless, + &[ + (tagged[0], "CreditTransfer"), + (tagged[0], "CreditTransfer again"), + ], + ); + let mut error = match result { + Ok(_) => panic!("two decoding framings must be refused"), + Err(error) => error, + }; assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter + error.code, + PlatformWalletFFIResultCode::ErrorDeserialization ); - assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + let message = unsafe { CStr::from_ptr(error.message) }.to_str().unwrap(); + assert!(message.contains("Ambiguous"), "{message}"); + unsafe { platform_wallet_ffi_result_free(&mut error) }; } #[test] - fn rejects_an_unsupported_state_transition_kind() { - let bytes = StateTransition::IdentityCreditTransfer( - IdentityCreditTransferTransitionV0 { - identity_id: Identifier::from([0x11; 32]), - recipient_id: Identifier::from([0x22; 32]), - amount: 1_000, - nonce: 1, - user_fee_increase: 0, - signature_public_key_id: 0, - signature: BinaryData::new(vec![0x99; 65]), - } - .into(), - ) - .serialize_to_bytes() - .expect("fixture credit transfer serializes"); + fn rejects_malformed_state_transition_bytes() { + let bytes = [0xde, 0xad, 0xbe, 0xef]; let (result, out) = parse(&bytes); assert_eq!( result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter + PlatformWalletFFIResultCode::ErrorDeserialization ); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert!(out.kind_name.is_null()); + assert!(out.serialized.is_null()); } #[test] - fn rejects_malformed_state_transition_bytes() { - let bytes = [0xde, 0xad, 0xbe, 0xef]; + fn rejects_a_document_type_that_cannot_cross_the_ffi_without_leaking() { + // The first row projects and owns two C strings; the second fails, so + // the error path has to release the first row. + let bytes = batch_transition_bytes( + vec![document_create("post"), document_create("pro\0file")], + vec![], + ); let (result, out) = parse(&bytes); assert_eq!( result.code, - PlatformWalletFFIResultCode::ErrorDeserialization + PlatformWalletFFIResultCode::ErrorInvalidParameter ); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert!(out.batch.transitions.is_null()); } #[test] @@ -577,5 +1342,6 @@ mod tests { assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); // Double free of an already-freed value must also be safe. unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + unsafe { platform_wallet_parse_state_transition_free(ptr::null_mut()) }; } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 402b6930737..ededc31ddb6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -114,6 +114,116 @@ pub fn identity_auth_derivation_path_for_type( ])) } +/// DIP-13 sub-feature under `m/9'/coin'/5'/` for the DashPay Connect +/// session authentication key: `m/9'/coin'/5'/6'/0'/identityId'/requestId'`. +/// The leaf is the connect request's id, `hash256(appEphemeralPubKey)`. +pub const CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION: u32 = 6; + +/// DIP-13 sub-feature under `m/9'/coin'/5'/` for the DashPay Connect +/// app encryption key pair: +/// `m/9'/coin'/5'/7'/0'/identityId'/contractId'/purpose'`. The leaf is +/// the id of the data contract the key is bound to; `purpose'` is the DPP +/// purpose discriminant (`1'` ENCRYPTION, `2'` DECRYPTION), so an identity +/// holds the DashPay-style pair per contract. +pub const CONNECT_SUB_FEATURE_APP_ENCRYPTION: u32 = 7; + +/// Build the DIP-13 sub-feature derivation path DashPay Connect keys live +/// at: +/// +/// ```text +/// m / 9' / coin' / 5' / sub_feature' / 0' / ' / ' [ / purpose' ] +/// ``` +/// +/// - `9'`, `coin'`, `5'` are the DIP-9 feature purpose, the network's coin +/// type (`5'` mainnet, `1'` otherwise, as the existing identity paths +/// pick it) and the DIP-13 identity feature. +/// - `sub_feature'` is a 31-bit hardened child; today `6'` (session +/// authentication) or `7'` (app encryption). DIP-13 assigns `0'`–`5'`. +/// - `0'` is the DIP-13 key type slot (ECDSA secp256k1). +/// - `identity_id'` and `leaf'` are DIP-14 256-bit hardened children +/// ([`ChildNumber::Hardened256`]), so nothing wallet-local (identity +/// ordinal, key counter) is an input and two devices restored from one +/// seed derive the same key without coordination. +/// - `purpose'`, when `Some`, is one further 31-bit hardened child. The +/// encryption sub-feature uses it to split the ENCRYPTION (`1'`) and +/// DECRYPTION (`2'`) halves of the pair; the authentication sub-feature +/// passes `None`. +/// +/// Values are not validated against the sub-feature (a `purpose` under +/// `6'` derives a key like any other); the caller picks the shape the +/// protocol asks for. The DIP-13 amendment registering `6'` and `7'` is +/// dashpay/dips#191. +pub fn connect_key_derivation_path( + network: key_wallet::Network, + sub_feature: u32, + identity_id: &Identifier, + leaf: [u8; 32], + purpose: Option, +) -> Result { + use key_wallet::dip9::{ + DASH_COIN_TYPE, DASH_TESTNET_COIN_TYPE, FEATURE_PURPOSE, FEATURE_PURPOSE_IDENTITIES, + }; + + let coin_type = match network { + key_wallet::Network::Mainnet => DASH_COIN_TYPE, + _ => DASH_TESTNET_COIN_TYPE, + }; + let hardened = |index: u32, what: &str| { + ChildNumber::from_hardened_idx(index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid {what} {index}: {e}")) + }) + }; + + let mut path = vec![ + hardened(FEATURE_PURPOSE, "feature purpose")?, + hardened(coin_type, "coin type")?, + hardened(FEATURE_PURPOSE_IDENTITIES, "identities feature")?, + hardened(sub_feature, "sub-feature")?, + hardened(u32::from(KeyDerivationType::ECDSA), "key type")?, + ChildNumber::from_hardened_idx_256(identity_id.to_buffer()), + ChildNumber::from_hardened_idx_256(leaf), + ]; + if let Some(purpose) = purpose { + path.push(hardened(purpose, "purpose")?); + } + Ok(DerivationPath::from(path)) +} + +/// Derive the ECDSA secp256k1 keypair at the DashPay Connect path built by +/// [`connect_key_derivation_path`] from a master xpriv. Pure, like +/// [`derive_ecdsa_identity_auth_keypair_from_master`], so it works for +/// watch-only wallets whose seed the FFI resolves on demand. The returned +/// [`DerivedIdentityAuthKey`] wraps the scalar in [`Zeroizing`]; its +/// `derivation_path` renders the 256-bit children as `0x…'`. +pub fn derive_connect_keypair_from_master( + master: &ExtendedPrivKey, + network: key_wallet::Network, + sub_feature: u32, + identity_id: &Identifier, + leaf: [u8; 32], + purpose: Option, +) -> Result { + use dashcore::secp256k1::Secp256k1; + use key_wallet::bip32::ExtendedPubKey; + + let path = connect_key_derivation_path(network, sub_feature, identity_id, leaf, purpose)?; + let secp = Secp256k1::new(); + // See `derive_ecdsa_identity_auth_keypair_from_master` for why the + // intermediate `ExtendedPrivKey` needs no explicit wipe. + let derived = master.derive_priv(&secp, &path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive connect key at sub-feature {sub_feature}: {e}" + )) + })?; + let extended_pub = ExtendedPubKey::from_priv(&secp, &derived); + + Ok(DerivedIdentityAuthKey { + derivation_path: path, + private_key: Zeroizing::new(derived.private_key.secret_bytes()), + public_key: extended_pub.public_key.serialize(), + }) +} + /// One ECDSA identity-authentication keypair derived from a master /// xpriv at a specific `(identity_index, key_index)` slot. Wraps /// the secret scalar in [`Zeroizing`] so it is wiped on drop — @@ -642,4 +752,256 @@ mod tests { ); } } + + // ── DashPay Connect sub-feature derivation ──────────────────────── + + const CONNECT_IDENTITY: [u8; 32] = [0x35; 32]; + const CONNECT_LEAF: [u8; 32] = [0x6B; 32]; + + /// The connect path has the documented shape: DIP-9 prefix, the + /// sub-feature, the ECDSA key-type slot, then two DIP-14 256-bit + /// hardened children, and an optional trailing 31-bit `purpose'`. + #[test] + fn connect_key_derivation_path_has_the_documented_shape() { + let identity = Identifier::from(CONNECT_IDENTITY); + + let auth = connect_key_derivation_path( + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ) + .expect("auth path builds"); + let auth_children: Vec = auth.as_ref().to_vec(); + assert_eq!( + auth_children, + vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Hardened { index: 1 }, + ChildNumber::Hardened { index: 5 }, + ChildNumber::Hardened { index: 6 }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Hardened256 { + index: CONNECT_IDENTITY + }, + ChildNumber::Hardened256 { + index: CONNECT_LEAF + }, + ] + ); + + let enc = connect_key_derivation_path( + Network::Mainnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + Some(1), + ) + .expect("encryption path builds"); + let enc_children: Vec = enc.as_ref().to_vec(); + assert_eq!(enc_children.len(), 8); + assert_eq!( + enc_children[1], + ChildNumber::Hardened { index: 5 }, + "mainnet coin type" + ); + assert_eq!(enc_children[3], ChildNumber::Hardened { index: 7 }); + assert_eq!( + enc_children[7], + ChildNumber::Hardened { index: 1 }, + "purpose'" + ); + + // Every level is hardened: nothing about these keys is derivable + // from a public parent. + assert!(auth_children.iter().all(ChildNumber::is_hardened)); + assert!(enc_children.iter().all(ChildNumber::is_hardened)); + + // A sub-feature outside the 31-bit hardened range is refused rather + // than silently masked. + assert!(connect_key_derivation_path( + Network::Testnet, + 1 << 31, + &identity, + CONNECT_LEAF, + None + ) + .is_err()); + } + + /// Fixed-vector determinism for the connect derivation: the same seed, + /// identity id and leaf always give the same key, on both networks, and + /// the derived public key is the compressed secp256k1 point of the + /// returned scalar. The pinned pubkeys let a future regression on the + /// Rust side (or a drift in the Swift/Kotlin ports, which re-derive the + /// same vector through the FFI) be spotted from either end. + #[test] + fn connect_keypair_is_deterministic_from_fixed_vectors() { + use dashcore::secp256k1::{PublicKey as SecpPublicKey, Secp256k1, SecretKey}; + + let identity = Identifier::from(CONNECT_IDENTITY); + let secp = Secp256k1::new(); + + for network in [Network::Mainnet, Network::Testnet] { + let master = master_for(network); + let first = derive_connect_keypair_from_master( + &master, + network, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ) + .expect("connect derive"); + let second = derive_connect_keypair_from_master( + &master, + network, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ) + .expect("connect derive"); + + assert_eq!(*first.private_key, *second.private_key); + assert_eq!(first.public_key, second.public_key); + assert_eq!(first.public_key.len(), 33); + assert!(first.public_key[0] == 0x02 || first.public_key[0] == 0x03); + + let sk = SecretKey::from_slice(first.private_key.as_ref()).expect("valid scalar"); + let pk = SecpPublicKey::from_secret_key(&secp, &sk); + assert_eq!(pk.serialize(), first.public_key, "pubkey matches scalar"); + + // The rendered path spells the 256-bit children as hex so the + // breadcrumb a client persists is unambiguous. + let rendered = first.derivation_path.to_string(); + assert!( + rendered.contains("/0x3535"), + "256-bit identity child rendered as hex: {rendered}" + ); + } + + // Pinned vectors for the test mnemonic (all-zero entropy), identity + // `[0x35; 32]`, leaf `[0x6B; 32]`. + let testnet_auth = derive_connect_keypair_from_master( + &master_for(Network::Testnet), + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ) + .expect("connect derive"); + let mainnet_enc = derive_connect_keypair_from_master( + &master_for(Network::Mainnet), + Network::Mainnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + Some(1), + ) + .expect("connect derive"); + assert_eq!( + ( + hex::encode(testnet_auth.public_key), + hex::encode(mainnet_enc.public_key) + ), + ( + CONNECT_TESTNET_AUTH_PUBKEY_HEX.to_string(), + CONNECT_MAINNET_ENCRYPTION_PUBKEY_HEX.to_string() + ), + "connect pubkey vectors drifted (testnet session-auth, mainnet app-encryption)" + ); + } + + /// Changing any input (leaf, purpose, sub-feature, identity, network) + /// changes the key. In particular the ENCRYPTION and DECRYPTION halves + /// of a pair under one contract must differ, and a session key must not + /// collide with an encryption key that happens to share its leaf. + #[test] + fn connect_keypair_differs_across_leaves_purposes_and_sub_features() { + let identity = Identifier::from(CONNECT_IDENTITY); + let other_identity = Identifier::from([0x36; 32]); + let master = master_for(Network::Testnet); + let derive = |network: Network, sub: u32, id: &Identifier, leaf: [u8; 32], p| { + derive_connect_keypair_from_master(&master, network, sub, id, leaf, p) + .expect("connect derive") + .public_key + }; + + let auth = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ); + let auth_other_leaf = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + [0x6C; 32], + None, + ); + let auth_other_identity = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &other_identity, + CONNECT_LEAF, + None, + ); + let enc = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + Some(1), + ); + let dec = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + Some(2), + ); + let enc_no_purpose = derive( + Network::Testnet, + CONNECT_SUB_FEATURE_APP_ENCRYPTION, + &identity, + CONNECT_LEAF, + None, + ); + let auth_mainnet = derive( + Network::Mainnet, + CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + &identity, + CONNECT_LEAF, + None, + ); + + let all = [ + auth, + auth_other_leaf, + auth_other_identity, + enc, + dec, + enc_no_purpose, + auth_mainnet, + ]; + for (i, a) in all.iter().enumerate() { + for b in &all[i + 1..] { + assert_ne!(a, b, "connect keys collided (index {i})"); + } + } + } + + // `hex::encode` of the compressed pubkeys the two pinned derivations + // above produce. Regenerate deliberately (and update the Swift and + // Kotlin vectors) if the path ever changes; a silent change here would + // orphan every key already registered on-chain. + const CONNECT_TESTNET_AUTH_PUBKEY_HEX: &str = + "022c8b2e806244482374b1caf8306146dc03aad3b99a5954efd5e70a0eddd37a5d"; + const CONNECT_MAINNET_ENCRYPTION_PUBKEY_HEX: &str = + "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434"; } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index ec766a7d64e..010647b9678 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -86,9 +86,11 @@ pub use dpns_marketplace::{ DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS, }; pub use identity_handle::{ + connect_key_derivation_path, derive_connect_keypair_from_master, derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, derive_identity_auth_keypair, identity_auth_derivation_path_for_type, DerivedIdentityAuthKey, - IdentityWallet, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, + IdentityWallet, CONNECT_SUB_FEATURE_APP_ENCRYPTION, CONNECT_SUB_FEATURE_SESSION_AUTHENTICATION, + IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; // Helpers declared on `identity_handle.rs` that siblings reach diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 475a12eb2e3..ba38432009d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -271,6 +271,17 @@ impl IdentityWallet { /// **not** look up the identity in the internal `IdentityManager`. The /// caller supplies the `Identity`, master key ID, and a `Signer` directly. /// + /// Keys in `add_public_keys` may be `IdentityPublicKey::V1` carrying a + /// `total_budget` and/or `expires_at` (protocol version 14). The + /// transition builder turns each key into the matching + /// `IdentityPublicKeyInCreation` version, so a V1 key is registered + /// with its limits inside the signed bytes and a V0 key is registered + /// exactly as before. The consensus rules on limits (AUTHENTICATION + /// purpose and a level below MASTER, non-zero budget, expiry in the + /// future) are not re-checked here: Platform validates them and the + /// caller receives the chain's error. See + /// `docs/protocol/authentication-key-limits.md`. + /// /// Returns the [`StateTransitionProofResult`] from the broadcast so callers /// can inspect proof-verified outcomes (e.g. updated keys, balance). pub async fn update_identity_with_signer>( @@ -320,3 +331,114 @@ impl IdentityWallet { Ok(result) } } + +#[cfg(test)] +mod tests { + //! `update_identity_with_signer` hands its `add_public_keys` straight to + //! `IdentityUpdateTransition::try_from_identity_with_signer`; the network + //! round trip around that call is not unit-testable, so these tests pin + //! the builder step: a V1 key with limits must reach the transition as a + //! V1 key in creation, with the limits inside the signed bytes, while a + //! V0 key stays V0. + + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; + use dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; + use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV1Getters; + use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + use dpp::state_transition::StateTransition; + use dpp::version::PlatformVersion; + use simple_signer::signer::SimpleSigner; + + /// A deterministic secp256k1 key pair for the master key that signs the + /// update and for the key being added (the added key signs its own + /// witness because ECDSA_SECP256K1 is a unique key type). + fn keypair(seed: u8) -> ([u8; 32], Vec) { + let secret = [seed; 32]; + let secp = dashcore::secp256k1::Secp256k1::new(); + let sk = dashcore::secp256k1::SecretKey::from_slice(&secret).expect("valid scalar"); + let pk = dashcore::secp256k1::PublicKey::from_secret_key(&secp, &sk); + (secret, pk.serialize().to_vec()) + } + + fn auth_key(id: u32, level: SecurityLevel, data: Vec) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: data.into(), + disabled_at: None, + }) + } + + async fn build_update(add_public_keys: Vec) -> IdentityUpdateTransition { + let (master_secret, master_pub) = keypair(0x11); + let master = auth_key(0, SecurityLevel::MASTER, master_pub); + + let mut identity = Identity::default_versioned(PlatformVersion::latest()).unwrap(); + identity.add_public_key(master.clone()); + + let mut signer = SimpleSigner::default(); + signer.add_identity_public_key(master, master_secret); + for key in &add_public_keys { + // Every added key in this module is derived from `keypair(0x22)`. + signer.add_identity_public_key(key.clone(), keypair(0x22).0); + } + + let transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &0, + add_public_keys, + vec![], + 1, + 0, + &signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("identity update builds"); + + match transition { + StateTransition::IdentityUpdate(update) => update, + other => panic!("expected an IdentityUpdate, got {}", other.name()), + } + } + + #[tokio::test] + async fn limited_key_is_registered_as_a_v1_key_in_creation() { + let (_, session_pub) = keypair(0x22); + let session = auth_key(5, SecurityLevel::HIGH, session_pub) + .with_limits(Some(10_000_000_000), Some(1_800_000_000_000)); + assert!(matches!(session, IdentityPublicKey::V1(_))); + + let update = build_update(vec![session]).await; + + let [added] = update.public_keys_to_add() else { + panic!("expected exactly one added key"); + }; + assert!(matches!(added, IdentityPublicKeyInCreation::V1(_))); + assert_eq!(added.total_budget(), Some(10_000_000_000)); + assert_eq!(added.expires_at(), Some(1_800_000_000_000)); + } + + #[tokio::test] + async fn unlimited_key_stays_a_v0_key_in_creation() { + let (_, plain_pub) = keypair(0x22); + let plain = auth_key(5, SecurityLevel::HIGH, plain_pub); + + let update = build_update(vec![plain]).await; + + let [added] = update.public_keys_to_add() else { + panic!("expected exactly one added key"); + }; + assert!(matches!(added, IdentityPublicKeyInCreation::V0(_))); + assert!(!added.has_limits()); + } +} diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e2604b7ab2d..e4a7fcad504 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -28,6 +28,9 @@ dashcore = { workspace = true } # Anchors the cross-language golden-fixture test to the canonical DashPay # contract id, so the mirrored Kotlin constant can't drift undetected. dashpay-contract = { path = "../dashpay-contract" } +# Builds the state-transition fixtures whose parsed blobs are the goldens +# shared with the Kotlin `StateTransitionParserTest`. +dpp = { path = "../rs-dpp" } [features] default = ["shielded"] diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 48418e3d66c..09e734eb36a 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -37,6 +37,7 @@ use jni::objects::{JByteArray, JClass, JString, JValue}; use jni::sys::{jboolean, jbyteArray, jint, jlong, jobject}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; +use platform_wallet_ffi::derive_connect_key::ConnectDerivedKeyFFI; use platform_wallet_ffi::error::platform_wallet_ffi_result_free; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_discovery::DiscoveredIdentityIdsFFI; @@ -482,6 +483,95 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive }) } +/// DashPay Connect key derivation at the DIP-13 sub-feature paths +/// (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`, +/// dashpay/dips#191). Thin marshaler over +/// `dash_sdk_derive_connect_key_with_resolver`: resolver-keyed and pure +/// like its slot-derive siblings, so it is safe from persistence-callback +/// context. Returns `[privateKey: byte[32], publicKey: byte[33]]`; the +/// Rust-side key is zeroized before return and Kotlin owns the copy. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_deriveConnectKeyWithResolver( + mut env: JNIEnv, + _class: JClass, + network_ord: jint, + wallet_id: JByteArray, + resolver_handle: jlong, + sub_feature: jint, + identity_id: JByteArray, + leaf: JByteArray, + purpose: jint, +) -> jni::sys::jobjectArray { + guard(&mut env, ptr::null_mut(), |env| { + if sub_feature < 0 { + throw_sdk_exception(env, 1, "subFeature must be non-negative"); + return ptr::null_mut(); + } + if !(0..=2).contains(&purpose) { + throw_sdk_exception( + env, + 1, + "purpose must be 0 (none), 1 (ENCRYPTION) or 2 (DECRYPTION)", + ); + return ptr::null_mut(); + } + if resolver_handle == 0 { + throw_sdk_exception(env, 1, "resolverHandle must be non-zero"); + return ptr::null_mut(); + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let Some(identity) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let Some(leaf) = read_id32(env, &leaf, "leaf") else { + return ptr::null_mut(); + }; + + let mut out = ConnectDerivedKeyFFI::empty(); + let result = unsafe { + platform_wallet_ffi::dash_sdk_derive_connect_key_with_resolver( + net_from_ord(network_ord), + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + sub_feature as u32, + identity.as_ptr(), + leaf.as_ptr(), + purpose as u32, + &mut out as *mut ConnectDerivedKeyFFI, + ) + }; + if take_pwffi_error(env, result) { + unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(&mut out) }; + return ptr::null_mut(); + } + + // Copy both halves before the free wipes the inline buffers; the + // private copy is zeroized on drop. + let scalar = zeroize::Zeroizing::new(out.private_key_bytes); + let pubkey = out.public_key_bytes; + unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(&mut out) }; + + let build = (|| -> Result { + let priv_arr = env.byte_array_from_slice(&*scalar)?; + let pub_arr = env.byte_array_from_slice(&pubkey)?; + let byte_array_class = env.find_class("[B")?; + let result = + env.new_object_array(2, byte_array_class, jni::objects::JObject::null())?; + env.set_object_array_element(&result, 0, priv_arr)?; + env.set_object_array_element(&result, 1, pub_arr)?; + Ok(result.into_raw()) + })(); + build.unwrap_or_else(|_| { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "connect key marshalling failed"); + ptr::null_mut() + }) + }) +} + // ── Registration (wallet-balance funded) ────────────────────────────── /// Resume a previously interrupted identity registration from a tracked diff --git a/packages/rs-unified-sdk-jni/src/lib.rs b/packages/rs-unified-sdk-jni/src/lib.rs index 3e9caf17b7b..e955750016a 100644 --- a/packages/rs-unified-sdk-jni/src/lib.rs +++ b/packages/rs-unified-sdk-jni/src/lib.rs @@ -21,6 +21,7 @@ mod events; mod funding; mod identity; mod mnemonic; +mod parse_state_transition; mod persistence; mod pubkey_rows; mod queries; diff --git a/packages/rs-unified-sdk-jni/src/parse_state_transition.rs b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs new file mode 100644 index 00000000000..a8a42d1f442 --- /dev/null +++ b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs @@ -0,0 +1,525 @@ +//! JNI bridge for decode-any-kind state transition parsing — a thin +//! marshaler over `platform_wallet_parse_state_transition` (single Rust FFI +//! entry point, per the `packages/kotlin-sdk/CLAUDE.md` boundary rule). +//! +//! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.TransactionsNative +//! .parseStateTransition`, driven by +//! `org.dashfoundation.dashsdk.identity.StateTransitionParser` — the +//! Android analog of Swift's `ManagedPlatformWallet.parseStateTransition`. +//! +//! ## Result convention +//! +//! `ParsedStateTransitionFFI` is a pointer graph (owned C strings and +//! arrays per kind). Rather than exposing a native handle plus N accessors +//! across JNI, the whole result is copied ONCE into a packed big-endian +//! blob (the convention `tx_decode.rs` and `pubkey_rows.rs` use) and every +//! Rust allocation is released via `platform_wallet_parse_state_transition_free` +//! before the export returns. Errors throw `DashSDKException` through the +//! shared `take_pwffi_error` mapping. +//! +//! ## BLOB layout (big-endian; keep in sync with `StateTransitionParser.parseBlob`) +//! +//! ```text +//! u8 kind (PARSED_STATE_TRANSITION_KIND_*) +//! u16 kind_name_len, u8[kind_name_len] kind_name (UTF-8) +//! u8 has_owner_id; if 1: u8[32] owner_id +//! u8 is_signed +//! u32 serialized_len, u8[serialized_len] serialized (tagged DPP bytes) +//! kind 1 (IdentityUpdate): +//! u8[32] identity_id +//! u32 add_count; repeat add_count times (same field order as +//! `IdentityPubkeyCodec`): +//! u32 key_id, u8 key_type, u8 purpose, u8 security_level, u8 read_only, +//! u8 contract_bounds_kind, u16 data_len, u8[data_len] data, +//! if contract_bounds_kind != 0: u8[32] contract_bounds_id, +//! if contract_bounds_kind == 2: u16 doc_type_len, u8[doc_type_len] doc_type, +//! u8 limits_flags (bit 0: u64 total_budget follows, bit 1: u64 expires_at follows) +//! u32 disable_count, u32[disable_count] disable_ids +//! kind 2 (Batch): +//! u8[32] owner_id +//! u32 count; repeat count times: +//! u8 family (0 document, 1 token), u8[32] data_contract_id, +//! u16 action_len, u8[action_len] action, +//! family 0: u16 doc_type_len, u8[doc_type_len] doc_type, u8[32] document_id +//! family 1: u16 token_contract_position, u8[32] token_id +//! u8 has_amount; if 1: u64 amount +//! u8 has_recipient; if 1: u8[32] recipient_id +//! kind 3 (IdentityCreditTransfer): +//! u8[32] identity_id, u8[32] recipient_id, u64 amount +//! kind 4 / 5 (DataContractCreate / DataContractUpdate): +//! u8[32] contract_id, u8[32] owner_id, +//! u32 count; repeat count times: u16 len, u8[len] document_type_name +//! kind 255 (other): nothing further +//! ``` + +use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; +use jni::objects::{JByteArray, JClass}; +use jni::sys::jbyteArray; +use jni::JNIEnv; +use platform_wallet_ffi::identity_update::ParsedIdentityUpdatePublicKeyFFI; +use platform_wallet_ffi::parse_state_transition::{ + platform_wallet_parse_state_transition, platform_wallet_parse_state_transition_free, + ParsedBatchedTransitionFFI, ParsedStateTransitionFFI, + PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT, PARSED_STATE_TRANSITION_KIND_BATCH, + PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER, + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE, + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE, + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE, +}; +use std::ffi::CStr; +use std::os::raw::c_char; +use std::ptr; + +/// Append `u16 len + bytes` for a required C string. A null pointer or an +/// over-long string is a bug in the producing FFI, so it is reported as an +/// error rather than silently encoded as empty. +/// +/// # Safety +/// `ptr` must be null or a valid NUL-terminated C string. +unsafe fn push_cstr(blob: &mut Vec, ptr: *const c_char, what: &str) -> Result<(), String> { + if ptr.is_null() { + return Err(format!("{what} is null")); + } + let bytes = CStr::from_ptr(ptr).to_bytes(); + let len = u16::try_from(bytes.len()).map_err(|_| format!("{what} exceeds u16 length"))?; + blob.extend_from_slice(&len.to_be_bytes()); + blob.extend_from_slice(bytes); + Ok(()) +} + +/// # Safety +/// `key` must be a row owned by a live `ParsedIdentityUpdateFFI`. +unsafe fn push_parsed_public_key( + blob: &mut Vec, + key: &ParsedIdentityUpdatePublicKeyFFI, +) -> Result<(), String> { + blob.extend_from_slice(&key.key_id.to_be_bytes()); + blob.push(key.key_type); + blob.push(key.purpose); + blob.push(key.security_level); + blob.push(u8::from(key.read_only)); + blob.push(key.contract_bounds_kind); + let data_len = u16::try_from(key.data_len).map_err(|_| "key data exceeds u16 length")?; + blob.extend_from_slice(&data_len.to_be_bytes()); + if key.data_len > 0 { + if key.data_ptr.is_null() { + return Err("key data pointer is null".to_string()); + } + blob.extend_from_slice(std::slice::from_raw_parts(key.data_ptr, key.data_len)); + } + if key.contract_bounds_kind != 0 { + blob.extend_from_slice(&key.contract_bounds_id); + } + if key.contract_bounds_kind == 2 { + push_cstr( + blob, + key.contract_bounds_document_type, + "contract bounds document type", + )?; + } + let flags = u8::from(key.has_total_budget) | (u8::from(key.has_expires_at) << 1); + blob.push(flags); + if key.has_total_budget { + blob.extend_from_slice(&key.total_budget.to_be_bytes()); + } + if key.has_expires_at { + blob.extend_from_slice(&key.expires_at.to_be_bytes()); + } + Ok(()) +} + +/// # Safety +/// `row` must be a row owned by a live `ParsedBatchFFI`. +unsafe fn push_batched_transition( + blob: &mut Vec, + row: &ParsedBatchedTransitionFFI, +) -> Result<(), String> { + blob.push(row.family); + blob.extend_from_slice(&row.data_contract_id); + push_cstr(blob, row.action, "batched transition action")?; + if row.family == PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT { + push_cstr(blob, row.document_type, "batched document type")?; + blob.extend_from_slice(&row.document_id); + } else { + blob.extend_from_slice(&row.token_contract_position.to_be_bytes()); + blob.extend_from_slice(&row.token_id); + } + blob.push(u8::from(row.has_amount)); + if row.has_amount { + blob.extend_from_slice(&row.amount.to_be_bytes()); + } + blob.push(u8::from(row.has_recipient)); + if row.has_recipient { + blob.extend_from_slice(&row.recipient_id); + } + Ok(()) +} + +fn push_count(blob: &mut Vec, count: usize, what: &str) -> Result<(), String> { + let count = u32::try_from(count).map_err(|_| format!("{what} count exceeds u32"))?; + blob.extend_from_slice(&count.to_be_bytes()); + Ok(()) +} + +/// Copy a `ParsedStateTransitionFFI` pointer graph into the packed blob. +/// +/// # Safety +/// `parsed` must be a successful, not yet freed result of +/// `platform_wallet_parse_state_transition`. +pub(crate) unsafe fn encode_parsed_state_transition( + parsed: &ParsedStateTransitionFFI, +) -> Result, String> { + let mut blob = Vec::with_capacity(256 + parsed.serialized_len); + blob.push(parsed.kind); + push_cstr(&mut blob, parsed.kind_name, "kind name")?; + blob.push(u8::from(parsed.has_owner_id)); + if parsed.has_owner_id { + blob.extend_from_slice(&parsed.owner_id); + } + blob.push(u8::from(parsed.is_signed)); + push_count(&mut blob, parsed.serialized_len, "serialized")?; + if parsed.serialized_len > 0 { + if parsed.serialized.is_null() { + return Err("serialized pointer is null".to_string()); + } + blob.extend_from_slice(std::slice::from_raw_parts( + parsed.serialized, + parsed.serialized_len, + )); + } + + match parsed.kind { + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE => { + let update = &parsed.identity_update; + blob.extend_from_slice(&update.identity_id); + push_count(&mut blob, update.add_public_keys_count, "added keys")?; + if update.add_public_keys_count > 0 { + if update.add_public_keys.is_null() { + return Err("added keys pointer is null".to_string()); + } + for key in + std::slice::from_raw_parts(update.add_public_keys, update.add_public_keys_count) + { + push_parsed_public_key(&mut blob, key)?; + } + } + push_count( + &mut blob, + update.disable_public_key_ids_count, + "disabled keys", + )?; + if update.disable_public_key_ids_count > 0 { + if update.disable_public_key_ids.is_null() { + return Err("disabled key ids pointer is null".to_string()); + } + for id in std::slice::from_raw_parts( + update.disable_public_key_ids, + update.disable_public_key_ids_count, + ) { + blob.extend_from_slice(&id.to_be_bytes()); + } + } + } + PARSED_STATE_TRANSITION_KIND_BATCH => { + let batch = &parsed.batch; + blob.extend_from_slice(&batch.owner_id); + push_count(&mut blob, batch.transitions_count, "batched transitions")?; + if batch.transitions_count > 0 { + if batch.transitions.is_null() { + return Err("batched transitions pointer is null".to_string()); + } + for row in std::slice::from_raw_parts(batch.transitions, batch.transitions_count) { + push_batched_transition(&mut blob, row)?; + } + } + } + PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER => { + let transfer = &parsed.credit_transfer; + blob.extend_from_slice(&transfer.identity_id); + blob.extend_from_slice(&transfer.recipient_id); + blob.extend_from_slice(&transfer.amount.to_be_bytes()); + } + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE + | PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE => { + let contract = &parsed.data_contract; + blob.extend_from_slice(&contract.contract_id); + blob.extend_from_slice(&contract.owner_id); + push_count( + &mut blob, + contract.document_type_names_count, + "document type names", + )?; + if contract.document_type_names_count > 0 { + if contract.document_type_names.is_null() { + return Err("document type names pointer is null".to_string()); + } + for name in std::slice::from_raw_parts( + contract.document_type_names, + contract.document_type_names_count, + ) { + push_cstr(&mut blob, *name, "document type name")?; + } + } + } + _ => {} + } + Ok(blob) +} + +/// Decode `bytes` as any DPP state transition and return the packed blob +/// described in the module doc. Throws `DashSDKException` on +/// undecodable bytes. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_parseStateTransition( + mut env: JNIEnv, + _class: JClass, + transition_bytes: JByteArray, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let bytes = match env.convert_byte_array(&transition_bytes) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "transitionBytes byte[] was null/invalid"); + return ptr::null_mut(); + } + }; + if bytes.is_empty() { + throw_sdk_exception(env, 1, "transitionBytes must not be empty"); + return ptr::null_mut(); + } + + let mut out = ParsedStateTransitionFFI::default(); + let result = unsafe { + platform_wallet_parse_state_transition(bytes.as_ptr(), bytes.len(), &mut out) + }; + if take_pwffi_error(env, result) { + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + return ptr::null_mut(); + } + + let encoded = unsafe { encode_parsed_state_transition(&out) }; + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + + match encoded { + Ok(blob) => env + .byte_array_from_slice(&blob) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()), + Err(message) => { + throw_sdk_exception( + env, + 99, + &format!("parsed transition encode failed: {message}"), + ); + ptr::null_mut() + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::prelude::Identifier; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::batch_transition::batched_transition::token_transfer_transition::v0::TokenTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::{ + BatchedTransition, TokenTransition, + }; + use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; + use dpp::state_transition::batch_transition::{ + BatchTransition, BatchTransitionV1, TokenTransferTransition, + }; + use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use dpp::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; + use dpp::state_transition::StateTransition; + + /// A DashPay Connect session-key registration: one HIGH auth key bound + /// to a contract group with a budget and an expiry, one key disabled. + /// The same fixture `StateTransitionParserTest` decodes on the Kotlin + /// side from the pinned hex below. + fn identity_update_bytes() -> Vec { + StateTransition::IdentityUpdate( + IdentityUpdateTransitionV0 { + signature: BinaryData::new(vec![]), + signature_public_key_id: 0, + identity_id: Identifier::from([0x11; 32]), + revision: 7, + nonce: 9, + add_public_keys: vec![IdentityPublicKeyInCreationV1 { + id: 18, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + read_only: false, + data: BinaryData::new(vec![0x03; 33]), + signature: BinaryData::new(vec![]), + contract_bounds: Some(ContractBounds::ContractGroup { + id: Identifier::from([0x66; 32]), + }), + total_budget: Some(10_000_000_000), + expires_at: Some(1_800_000_000_000), + } + .into()], + disable_public_keys: vec![4], + user_fee_increase: 0, + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture identity update serializes") + } + + fn token_transfer_batch_bytes() -> Vec { + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from([0x21; 32]), + transitions: vec![BatchedTransition::Token(TokenTransition::Transfer( + TokenTransferTransition::V0(TokenTransferTransitionV0 { + base: TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 4, + token_contract_position: 3, + data_contract_id: Identifier::from([0x42; 32]), + token_id: Identifier::from([0x77; 32]), + using_group_info: None, + }), + amount: 250, + recipient_id: Identifier::from([0x22; 32]), + public_note: None, + shared_encrypted_note: None, + private_encrypted_note: None, + }), + ))], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + })) + .serialize_to_bytes() + .expect("fixture batch serializes") + } + + fn parse_to_blob(bytes: &[u8]) -> Vec { + let mut out = ParsedStateTransitionFFI::default(); + let result = unsafe { + platform_wallet_parse_state_transition(bytes.as_ptr(), bytes.len(), &mut out) + }; + assert_eq!( + result.code, + platform_wallet_ffi::error::PlatformWalletFFIResultCode::Success + ); + let blob = unsafe { encode_parsed_state_transition(&out) }.expect("blob encodes"); + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + blob + } + + struct Reader<'a>(&'a [u8]); + impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> &'a [u8] { + let (head, tail) = self.0.split_at(n); + self.0 = tail; + head + } + fn u8(&mut self) -> u8 { + self.take(1)[0] + } + fn u16(&mut self) -> u16 { + u16::from_be_bytes(self.take(2).try_into().unwrap()) + } + fn u32(&mut self) -> u32 { + u32::from_be_bytes(self.take(4).try_into().unwrap()) + } + fn u64(&mut self) -> u64 { + u64::from_be_bytes(self.take(8).try_into().unwrap()) + } + fn str16(&mut self) -> String { + let len = self.u16() as usize; + String::from_utf8(self.take(len).to_vec()).unwrap() + } + } + + #[test] + fn identity_update_blob_round_trips_and_is_pinned_for_kotlin() { + let bytes = identity_update_bytes(); + let blob = parse_to_blob(&bytes); + + let mut r = Reader(&blob); + assert_eq!(r.u8(), PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE); + assert_eq!(r.str16(), "IdentityUpdate"); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x11; 32]); + assert_eq!(r.u8(), 0, "unsigned"); + let serialized_len = r.u32() as usize; + assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.take(32), &[0x11; 32]); + assert_eq!(r.u32(), 1); + assert_eq!(r.u32(), 18); + assert_eq!(r.u8(), 0); // key type + assert_eq!(r.u8(), 0); // purpose + assert_eq!(r.u8(), 2); // HIGH + assert_eq!(r.u8(), 0); // read only + assert_eq!(r.u8(), 3); // ContractGroup + assert_eq!(r.u16(), 33); + assert_eq!(r.take(33), &[0x03; 33]); + assert_eq!(r.take(32), &[0x66; 32]); + assert_eq!(r.u8(), 0b11); + assert_eq!(r.u64(), 10_000_000_000); + assert_eq!(r.u64(), 1_800_000_000_000); + assert_eq!(r.u32(), 1); + assert_eq!(r.u32(), 4); + assert!(r.0.is_empty(), "trailing bytes"); + + assert_eq!( + blob, IDENTITY_UPDATE_GOLDEN, + "identity-update blob drifted from the golden shared with StateTransitionParserTest" + ); + } + + #[test] + fn token_transfer_batch_blob_round_trips_and_is_pinned_for_kotlin() { + let bytes = token_transfer_batch_bytes(); + let blob = parse_to_blob(&bytes); + + let mut r = Reader(&blob); + assert_eq!(r.u8(), PARSED_STATE_TRANSITION_KIND_BATCH); + assert_eq!(r.str16(), "DocumentsBatch([TokenTransfer])"); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u8(), 0); + let serialized_len = r.u32() as usize; + assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u32(), 1); + assert_eq!(r.u8(), 1); // token family + assert_eq!(r.take(32), &[0x42; 32]); + assert_eq!(r.str16(), "Transfer"); + assert_eq!(r.u16(), 3); + assert_eq!(r.take(32), &[0x77; 32]); + assert_eq!(r.u8(), 1); + assert_eq!(r.u64(), 250); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x22; 32]); + assert!(r.0.is_empty(), "trailing bytes"); + + assert_eq!( + blob, TOKEN_TRANSFER_GOLDEN, + "token-transfer blob drifted from the golden shared with StateTransitionParserTest" + ); + } + + /// The checked-in golden blobs, shared byte-for-byte with the Kotlin + /// decoder test (`StateTransitionParserTest`). Referenced from the single + /// canonical copy in the Kotlin SDK's test resources so the two cannot + /// drift, the way `pubkey_rows.rs` anchors its registration golden. + const IDENTITY_UPDATE_GOLDEN: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_identity_update_v1.bin" + )); + const TOKEN_TRANSFER_GOLDEN: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_token_transfer_batch_v1.bin" + )); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 84dae9131a2..f743ea6caff 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -164,7 +164,7 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// Rust avoids the prior pubkey-derivation pass inside /// `platform_wallet_register_identity_with_signer` that fails on /// watch-only wallets where Rust has no in-process xpriv loaded. - public struct IdentityPubkey: Sendable { + public struct IdentityPubkey: Sendable, Equatable { public let keyId: UInt32 public let keyType: KeyType public let purpose: KeyPurpose @@ -232,7 +232,7 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// Inspectable fields of a parsed raw `IdentityUpdateTransition`. /// The keys intentionally reuse `IdentityPubkey` so callers can /// validate and hand them back to `updateIdentity(...)` unchanged. - public struct ParsedIdentityUpdateTransition: Sendable { + public struct ParsedIdentityUpdateTransition: Sendable, Equatable { public let identityId: Identifier public let addPublicKeys: [IdentityPubkey] public let disablePublicKeyIds: [UInt32] @@ -248,49 +248,154 @@ public final class ManagedPlatformWallet: @unchecked Sendable { } } - /// Inspectable fields of a token direct purchase parsed out of a - /// raw `BatchTransition`. Carries everything `tokenPurchase(...)` - /// needs to rebuild the purchase after user approval, plus what - /// the user must see before approving. - public struct ParsedTokenPurchaseTransition: Sendable { - /// The identity whose credits pay for the purchase. - public let ownerId: Identifier - /// The data contract defining the token. - public let dataContractId: Identifier - /// The token being bought. - public let tokenId: Identifier - /// Position of the token within the contract. - public let tokenContractPosition: UInt16 - /// How many tokens the dApp asks to buy. - public let tokenCount: UInt64 - /// Credits the owner would agree to pay in total. - public let totalAgreedPrice: UInt64 - - public init( - ownerId: Identifier, + /// One transition inside a parsed `BatchTransition`, in batch order. + /// + /// `action` is the rs-dpp action name (`Create`, `Replace`, `Delete`, + /// `Transfer`, `Purchase`, `UpdatePrice`, `IndexOnlyDelete` for + /// documents; `Burn`, `Mint`, `Transfer`, `Freeze`, `Unfreeze`, + /// `DestroyFrozenFunds`, `Claim`, `EmergencyAction`, `ConfigUpdate`, + /// `DirectPurchase`, `SetPriceForDirectPurchase` for tokens). + public enum ParsedBatchedTransition: Sendable, Equatable { + /// A document transition within `dataContractId` / `documentType`. + /// `amount` is a purchase price or an update-price value; + /// `recipientId` is a transfer's new owner. + case document( + dataContractId: Identifier, + documentType: String, + documentId: Identifier, + action: String, + amount: UInt64?, + recipientId: Identifier? + ) + /// A token transition on `tokenId` at `tokenContractPosition` of + /// `dataContractId`. `amount` is the transferred / minted / burned + /// token count or a direct purchase's total agreed price; + /// `recipientId` is a transfer's recipient, a mint's issued-to + /// identity, or the frozen identity of a freeze / unfreeze / + /// destroy. Interpret both against `action`: a `Freeze` sheet + /// says "freeze the tokens of X", not "send to X". + case token( dataContractId: Identifier, tokenId: Identifier, tokenContractPosition: UInt16, - tokenCount: UInt64, - totalAgreedPrice: UInt64 - ) { + action: String, + amount: UInt64?, + recipientId: Identifier? + ) + + /// The data contract the transition acts within. + public var dataContractId: Identifier { + switch self { + case .document(let id, _, _, _, _, _), .token(let id, _, _, _, _, _): + return id + } + } + + /// The rs-dpp action name. + public var action: String { + switch self { + case .document(_, _, _, let action, _, _), .token(_, _, _, let action, _, _): + return action + } + } + } + + /// Inspectable fields of a parsed `BatchTransition`. + public struct ParsedBatchTransition: Sendable, Equatable { + /// The identity whose keys sign and whose credits pay. + public let ownerId: Identifier + /// One entry per batched transition, in order. + public let transitions: [ParsedBatchedTransition] + + public init(ownerId: Identifier, transitions: [ParsedBatchedTransition]) { self.ownerId = ownerId - self.dataContractId = dataContractId - self.tokenId = tokenId - self.tokenContractPosition = tokenContractPosition - self.tokenCount = tokenCount - self.totalAgreedPrice = totalAgreedPrice + self.transitions = transitions } } - /// One parsed `dash-st:` state transition, discriminated by case - /// so callers branch on the payload instead of on thrown errors. - public enum ParsedStateTransition: Sendable { - /// DashConnect key registration (`IdentityUpdateTransition`). + /// Inspectable fields of a parsed `IdentityCreditTransferTransition`. + public struct ParsedCreditTransferTransition: Sendable, Equatable { + public let identityId: Identifier + public let recipientId: Identifier + public let amount: UInt64 + + public init(identityId: Identifier, recipientId: Identifier, amount: UInt64) { + self.identityId = identityId + self.recipientId = recipientId + self.amount = amount + } + } + + /// Inspectable fields of a parsed `DataContractCreateTransition` or + /// `DataContractUpdateTransition`. + public struct ParsedDataContractTransition: Sendable, Equatable { + public let contractId: Identifier + public let ownerId: Identifier + /// Document type names the contract defines, as the contract + /// orders them. + public let documentTypeNames: [String] + + public init(contractId: Identifier, ownerId: Identifier, documentTypeNames: [String]) { + self.contractId = contractId + self.ownerId = ownerId + self.documentTypeNames = documentTypeNames + } + } + + /// The typed summary of one parsed state transition, discriminated + /// by kind so callers branch on the payload instead of on thrown + /// errors. Kinds this SDK has no describer for arrive as `.other`. + public enum ParsedStateTransitionKind: Sendable, Equatable { + /// Key registration / revocation (`IdentityUpdateTransition`). case identityUpdate(ParsedIdentityUpdateTransition) - /// dApp token purchase (a `BatchTransition` carrying exactly - /// one `TokenDirectPurchase`). - case tokenPurchase(ParsedTokenPurchaseTransition) + /// Document and token operations (`BatchTransition`). + case batch(ParsedBatchTransition) + /// `IdentityCreditTransferTransition`. + case creditTransfer(ParsedCreditTransferTransition) + /// `DataContractCreateTransition`. + case dataContractCreate(ParsedDataContractTransition) + /// `DataContractUpdateTransition`. + case dataContractUpdate(ParsedDataContractTransition) + /// Any other kind; see `ParsedStateTransition.kindName`. + case other + } + + /// One parsed state transition (a `dash-st:` payload or a DashPay + /// Connect `sign` request), decoded whatever its kind. + /// + /// `serialized` holds the bytes that actually decoded, always in + /// tagged DPP framing (the variant tag is prepended when the input + /// arrived tagless); after approval, sign these rather than the + /// input so what was shown is what is signed. A `sign` request must + /// arrive with `isSigned == false` and `ownerId` equal to the + /// wallet's own identity; both checks are the caller's. + public struct ParsedStateTransition: Sendable, Equatable { + /// rs-dpp `StateTransition::name()`, e.g. `IdentityUpdate`, + /// `DocumentsBatch([Create, TokenTransfer])`, `MasternodeVote`. + public let kindName: String + /// The identity the transition acts for; `nil` for the + /// asset-lock-funded and shielded kinds, which name none. + public let ownerId: Identifier? + /// Whether the transition already carries a signature. + public let isSigned: Bool + /// The decoded bytes, tagged. + public let serialized: Data + /// The typed summary. + public let kind: ParsedStateTransitionKind + + public init( + kindName: String, + ownerId: Identifier?, + isSigned: Bool, + serialized: Data, + kind: ParsedStateTransitionKind + ) { + self.kindName = kindName + self.ownerId = ownerId + self.isSigned = isSigned + self.serialized = serialized + self.kind = kind + } } /// Result of a successful identity registration. @@ -1252,16 +1357,22 @@ extension ManagedPlatformWallet { var row = IdentityKeyPreviewFFI() - let result = self.walletId.withUnsafeBytes { walletBytes -> PlatformWalletFFIResult in - let walletPtr = walletBytes.bindMemory(to: UInt8.self).baseAddress! - return dash_sdk_derive_identity_key_at_slot_with_resolver( - network.ffiValue, - walletPtr, - resolver.handle, - identityIndex, - keyId, - &row - ) + // `withExtendedLifetime` pins the resolver across the synchronous + // FFI call so ARC cannot deallocate its `passUnretained` ctx while + // Rust is still calling back into it (same rationale as + // `coreAddressPrivateKey`). + let result = withExtendedLifetime(resolver) { + self.walletId.withUnsafeBytes { walletBytes -> PlatformWalletFFIResult in + let walletPtr = walletBytes.bindMemory(to: UInt8.self).baseAddress! + return dash_sdk_derive_identity_key_at_slot_with_resolver( + network.ffiValue, + walletPtr, + resolver.handle, + identityIndex, + keyId, + &row + ) + } } defer { dash_sdk_derive_identity_key_at_slot_free(&row) } @@ -1295,6 +1406,132 @@ extension ManagedPlatformWallet { ) } + /// A DashPay Connect key derived at a DIP-13 sub-feature path (see + /// `deriveConnectKey(subFeature:identityId:leaf:purpose:)`). + public struct ConnectDerivedKey: Sendable, Equatable { + /// 33-byte compressed secp256k1 public key. + public let publicKeyData: Data + /// 32-byte private scalar. Hand it to the Keychain (or encrypt it + /// to the app) and drop this value as soon as possible. + public let privateKeyData: Data + + public init(publicKeyData: Data, privateKeyData: Data) { + self.publicKeyData = publicKeyData + self.privateKeyData = privateKeyData + } + } + + /// DIP-13 sub-features DashPay Connect v2 keys live under + /// (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`). + /// Registered by the DIP-13 amendment dashpay/dips#191. + public enum ConnectSubFeature: UInt32, Sendable { + /// Session authentication key: the leaf is the connect request id + /// (`hash256` of the app's ephemeral public key); no purpose level. + case sessionAuthentication = 6 + /// App encryption key pair: the leaf is the id of the data + /// contract the key is bound to; the purpose level is + /// `ConnectKeyPurpose.encryption` (1') or `.decryption` (2'). + case appEncryption = 7 + } + + /// The trailing `purpose'` level of the app-encryption path: the DPP + /// purpose discriminant of the half being derived. Only these two + /// exist in the DIP-13 amendment, so the type makes any other value + /// (in particular `0`, which would collide with the FFI's "no purpose + /// level") unrepresentable. + public enum ConnectKeyPurpose: UInt32, Sendable { + case encryption = 1 + case decryption = 2 + + /// The DPP `KeyPurpose` the derived half is registered under. + public var keyPurpose: KeyPurpose { + switch self { + case .encryption: return .encryption + case .decryption: return .decryption + } + } + } + + /// Derive a DashPay Connect key at + /// `m/9'/coin'/5'/'/0'/'/'[/']` + /// from this wallet's seed, resolved on demand through the + /// `MnemonicResolver` (the mnemonic never lives in a Swift `String` + /// outside the resolver trampoline; see `deriveIdentityAuthKeyAtSlot`). + /// + /// `identityId` and `leaf` are DIP-14 256-bit hardened children, so + /// nothing wallet-local is an input and two devices restored from one + /// seed derive the same key. `purpose`, when given, appends one more + /// hardened child: the encryption sub-feature uses `.encryption` and + /// `.decryption` to split its pair; the authentication sub-feature + /// takes `nil` (no purpose level at all). + /// + /// - Parameters: + /// - subFeature: `.sessionAuthentication` or `.appEncryption`. + /// - identityId: the identity's 32-byte id. + /// - leaf: the 32-byte request id or bound contract id. + /// - purpose: the half of an encryption pair to derive, or `nil`. + /// - network: the wallet's network, which selects the coin type. + /// - storage: defaults to a fresh `WalletStorage()`; overridable + /// for tests. Used by the resolver vtable. + @MainActor + public func deriveConnectKey( + subFeature: ConnectSubFeature, + identityId: Identifier, + leaf: Data, + purpose: ConnectKeyPurpose? = nil, + network: Network, + storage: WalletStorage = WalletStorage() + ) throws -> ConnectDerivedKey { + guard self.walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be 32 bytes, got \(self.walletId.count)" + ) + } + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "identityId must be 32 bytes, got \(identityId.count)" + ) + } + guard leaf.count == 32 else { + throw PlatformWalletError.invalidParameter( + "leaf must be 32 bytes, got \(leaf.count)" + ) + } + let resolver = MnemonicResolver(storage: storage) + + var out = ConnectDerivedKeyFFI() + defer { dash_sdk_derive_connect_key_free(&out) } + + // `withExtendedLifetime` pins the resolver across the synchronous + // FFI call so ARC cannot deallocate its `passUnretained` ctx while + // Rust is still calling back into it. + let result = withExtendedLifetime(resolver) { + self.walletId.withUnsafeBytes { walletBytes -> PlatformWalletFFIResult in + identityId.withUnsafeBytes { identityBytes -> PlatformWalletFFIResult in + leaf.withUnsafeBytes { leafBytes -> PlatformWalletFFIResult in + dash_sdk_derive_connect_key_with_resolver( + network.ffiValue, + walletBytes.bindMemory(to: UInt8.self).baseAddress!, + resolver.handle, + subFeature.rawValue, + identityBytes.bindMemory(to: UInt8.self).baseAddress!, + leafBytes.bindMemory(to: UInt8.self).baseAddress!, + purpose?.rawValue ?? 0, + &out + ) + } + } + } + } + try result.check() + + // Copy out of the inline tuples: the deferred free zeroizes them. + return ConnectDerivedKey( + publicKeyData: Self.tupleData(out.public_key_bytes), + privateKeyData: Self.tupleData(out.private_key_bytes) + ) + } + /// Pre-derive + pre-persist the authentication keys an upcoming /// `registerIdentityFromAddresses(...signer:)` call will use. /// @@ -3378,21 +3615,19 @@ extension ManagedPlatformWallet { } /// Parse a raw DPP state transition handed to the wallet by a dApp - /// (DashConnect `dash-st:` link / QR) without signing or - /// broadcasting it, reporting which supported kind it found. - /// Accepts both standard tagged bytes and Yappr's tagless framing. - /// - /// Supported kinds: an `IdentityUpdateTransition` (DashConnect key - /// registration) and a `BatchTransition` carrying exactly one - /// `TokenDirectPurchase` (a dApp token purchase). Anything else — - /// including multi-transition or mixed batches, which a user - /// cannot meaningfully approve as one prompt — throws with a - /// message naming what was found. - /// - /// The wallet never signs bytes a web page handed it. After the - /// user approves the parsed intent, rebuild and sign the - /// operation through the normal path: `tokenPurchase(...)` for - /// `.tokenPurchase`, `updateIdentity(...)` for `.identityUpdate`. + /// (a DashConnect `dash-st:` link / QR or a DashPay Connect `sign` + /// request) without signing or broadcasting it. Every kind decodes; + /// the result carries a typed summary for identity updates, batches, + /// credit transfers and data contract create / update, and `.other` + /// with the raw kind name for everything else, so the approval + /// sheet can describe what is asked and the user decides. Accepts + /// both standard tagged bytes and Yappr's tagless framing; the + /// returned `serialized` bytes are always tagged. + /// + /// The wallet never signs bytes a web page handed it blind. A + /// `sign` request must be unsigned and for the wallet's own + /// identity; check `isSigned` and `ownerId` before showing the + /// sheet, then sign `serialized` after approval. public func parseStateTransition(_ bytes: Data) throws -> ParsedStateTransition { guard !bytes.isEmpty else { throw PlatformWalletError.deserialization( @@ -3415,33 +3650,163 @@ extension ManagedPlatformWallet { try result.check() defer { platform_wallet_parse_state_transition_free(&out) } - switch out.kind { + return try Self.makeParsedStateTransition(from: out) + } + + /// Copy a fixed-size C byte tuple — how Swift imports a `uint8_t[N]` + /// field — out of an FFI struct into `Data`. The tuples are plain + /// bytes, so the value's raw representation is exactly the array it + /// stands for, and `Data` copies before the FFI free wipes the source. + fileprivate static func tupleData(_ tuple: Tuple) -> Data { + Swift.withUnsafeBytes(of: tuple) { Data($0) } + } + + // `internal` so the projection can be covered directly from a + // hand-built C struct; every production caller reaches it through + // a live FFI parse. + static func makeParsedStateTransition( + from ffi: ParsedStateTransitionFFI + ) throws -> ParsedStateTransition { + let kind: ParsedStateTransitionKind + switch ffi.kind { case 1: // PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE - return .identityUpdate( - try Self.makeParsedIdentityUpdateTransition(from: out.identity_update) + kind = .identityUpdate( + try makeParsedIdentityUpdateTransition(from: ffi.identity_update) + ) + case 2: // PARSED_STATE_TRANSITION_KIND_BATCH + kind = .batch(try makeParsedBatchTransition(from: ffi.batch)) + case 3: // PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER + kind = .creditTransfer( + ParsedCreditTransferTransition( + identityId: tupleData(ffi.credit_transfer.identity_id), + recipientId: tupleData(ffi.credit_transfer.recipient_id), + amount: ffi.credit_transfer.amount + ) + ) + case 4: // PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE + kind = .dataContractCreate( + try makeParsedDataContractTransition(from: ffi.data_contract) + ) + case 5: // PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE + kind = .dataContractUpdate( + try makeParsedDataContractTransition(from: ffi.data_contract) + ) + case 255: // PARSED_STATE_TRANSITION_KIND_OTHER + kind = .other + default: + throw PlatformWalletError.deserialization( + "Unknown parsed state-transition kind \(ffi.kind)" + ) + } + + guard let kindNamePtr = ffi.kind_name, + let kindName = String(validatingCString: kindNamePtr) else { + throw PlatformWalletError.deserialization( + "Parsed state transition is missing a valid UTF-8 kind name" + ) + } + + let ownerId: Identifier? = ffi.has_owner_id ? tupleData(ffi.owner_id) : nil + + let serialized: Data + if let serializedPtr = ffi.serialized, ffi.serialized_len > 0 { + serialized = Data(bytes: serializedPtr, count: Int(ffi.serialized_len)) + } else { + serialized = Data() + } + + return ParsedStateTransition( + kindName: kindName, + ownerId: ownerId, + isSigned: ffi.is_signed, + serialized: serialized, + kind: kind + ) + } + + private static func makeParsedBatchTransition( + from ffi: ParsedBatchFFI + ) throws -> ParsedBatchTransition { + let ownerId = tupleData(ffi.owner_id) + + var transitions: [ParsedBatchedTransition] = [] + if let pointer = ffi.transitions, ffi.transitions_count > 0 { + let buffer = UnsafeBufferPointer(start: pointer, count: Int(ffi.transitions_count)) + transitions = try buffer.enumerated().map { index, entry in + try makeParsedBatchedTransition(from: entry, index: index) + } + } + return ParsedBatchTransition(ownerId: ownerId, transitions: transitions) + } + + static func makeParsedBatchedTransition( + from entry: ParsedBatchedTransitionFFI, + index: Int + ) throws -> ParsedBatchedTransition { + guard let actionPtr = entry.action, + let action = String(validatingCString: actionPtr) else { + throw PlatformWalletError.deserialization( + "Batched transition \(index) is missing a valid UTF-8 action name" ) - case 2: // PARSED_STATE_TRANSITION_KIND_TOKEN_DIRECT_PURCHASE - let purchase = out.token_direct_purchase - var ownerTuple = purchase.owner_id - var contractTuple = purchase.data_contract_id - var tokenTuple = purchase.token_id - return .tokenPurchase( - ParsedTokenPurchaseTransition( - ownerId: Swift.withUnsafeBytes(of: &ownerTuple) { Data($0) }, - dataContractId: Swift.withUnsafeBytes(of: &contractTuple) { Data($0) }, - tokenId: Swift.withUnsafeBytes(of: &tokenTuple) { Data($0) }, - tokenContractPosition: purchase.token_contract_position, - tokenCount: purchase.token_count, - totalAgreedPrice: purchase.total_agreed_price + } + let dataContractId = tupleData(entry.data_contract_id) + let amount: UInt64? = entry.has_amount ? entry.amount : nil + let recipientId: Identifier? = entry.has_recipient ? tupleData(entry.recipient_id) : nil + + switch entry.family { + case 0: // PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT + guard let documentTypePtr = entry.document_type, + let documentType = String(validatingCString: documentTypePtr) else { + throw PlatformWalletError.deserialization( + "Batched document transition \(index) is missing a valid UTF-8 document type" ) + } + return .document( + dataContractId: dataContractId, + documentType: documentType, + documentId: tupleData(entry.document_id), + action: action, + amount: amount, + recipientId: recipientId + ) + case 1: // PARSED_BATCHED_TRANSITION_FAMILY_TOKEN + return .token( + dataContractId: dataContractId, + tokenId: tupleData(entry.token_id), + tokenContractPosition: entry.token_contract_position, + action: action, + amount: amount, + recipientId: recipientId ) default: throw PlatformWalletError.deserialization( - "Unknown parsed state-transition kind \(out.kind)" + "Unknown batched transition family \(entry.family) at index \(index)" ) } } + private static func makeParsedDataContractTransition( + from ffi: ParsedDataContractFFI + ) throws -> ParsedDataContractTransition { + var names: [String] = [] + if let pointer = ffi.document_type_names, ffi.document_type_names_count > 0 { + let buffer = UnsafeBufferPointer(start: pointer, count: Int(ffi.document_type_names_count)) + names = try buffer.enumerated().map { index, namePtr in + guard let namePtr, let name = String(validatingCString: namePtr) else { + throw PlatformWalletError.deserialization( + "Data contract document type name \(index) is not valid UTF-8" + ) + } + return name + } + } + return ParsedDataContractTransition( + contractId: tupleData(ffi.contract_id), + ownerId: tupleData(ffi.owner_id), + documentTypeNames: names + ) + } + private static func makeParsedIdentityUpdateTransition( from ffi: ParsedIdentityUpdateFFI ) throws -> ParsedIdentityUpdateTransition { @@ -3477,7 +3842,7 @@ extension ManagedPlatformWallet { ) } - private static func makeParsedIdentityPubkey( + static func makeParsedIdentityPubkey( from entry: ParsedIdentityUpdatePublicKeyFFI, index: Int ) throws -> IdentityPubkey { @@ -3509,7 +3874,9 @@ extension ManagedPlatformWallet { securityLevel: securityLevel, pubkeyBytes: pubkeyBytes, readOnly: entry.read_only, - contractBounds: contractBounds + contractBounds: contractBounds, + totalBudget: entry.has_total_budget ? entry.total_budget : nil, + expiresAt: entry.has_expires_at ? entry.expires_at : nil ) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift new file mode 100644 index 00000000000..74948c87997 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift @@ -0,0 +1,141 @@ +import XCTest + +@testable import SwiftDashSDK + +/// DashPay Connect key derivation at the DIP-13 sub-feature paths +/// (`m/9'/coin'/5'/{6|7}'/0'/identityId'/leaf'[/purpose']`, dashpay/dips#191) +/// through `ManagedPlatformWallet.deriveConnectKey`. The fixed vectors are +/// the ones `platform-wallet`'s +/// `connect_keypair_is_deterministic_from_fixed_vectors` pins on the Rust +/// side, so a drift on either side of the FFI is caught. +@MainActor +final class ConnectKeyDerivationTests: XCTestCase { + + // Canonical BIP-39 test vector (all-zero entropy). + private let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private let walletId = Data(repeating: 0x07, count: 32) + private let identityId = Data(repeating: 0x35, count: 32) + private let leaf = Data(repeating: 0x6B, count: 32) + + /// Pinned in `identity_handle.rs`: testnet, sub-feature 6', no purpose. + private let testnetAuthPubkeyHex = + "022c8b2e806244482374b1caf8306146dc03aad3b99a5954efd5e70a0eddd37a5d" + /// Pinned in `identity_handle.rs`: mainnet, sub-feature 7', purpose 1'. + private let mainnetEncryptionPubkeyHex = + "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434" + + /// In-memory `WalletStorage` so the resolver never touches the macOS + /// Keychain (see `IdentityResolverSignIntegrationTests`). + private final class InMemoryWalletStorage: WalletStorage { + private var mnemonics: [Data: Data] = [:] + private let lock = NSLock() + + override func storeMnemonic(_ mnemonic: String, for walletId: Data) throws { + lock.lock() + defer { lock.unlock() } + mnemonics[walletId] = Data(mnemonic.utf8) + } + + override func retrieveMnemonicUTF8Bytes(for walletId: Data) throws -> Data { + lock.lock() + defer { lock.unlock() } + guard let data = mnemonics[walletId], !data.isEmpty else { + throw WalletStorageError.mnemonicNotFound + } + return data + } + + override func deleteMnemonic(for walletId: Data) throws { + lock.lock() + defer { lock.unlock() } + mnemonics[walletId] = nil + } + + override func mnemonicAvailability(for walletId: Data) -> MnemonicAvailability { + lock.lock() + defer { lock.unlock() } + return mnemonics[walletId] != nil ? .present : .absent + } + } + + private func makeWallet() throws -> (ManagedPlatformWallet, WalletStorage) { + let storage = InMemoryWalletStorage() + try storage.storeMnemonic(mnemonic, for: walletId) + // The derive is resolver-driven and never touches the native + // wallet handle, so a null handle is fine here. + return (ManagedPlatformWallet(handle: NULL_HANDLE, walletId: walletId), storage) + } + + func testDerivesThePinnedVectorsDeterministically() throws { + let (wallet, storage) = try makeWallet() + + let auth = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .testnet, storage: storage) + let authAgain = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .testnet, storage: storage) + XCTAssertEqual(auth, authAgain, "same inputs derive the same key") + XCTAssertEqual(auth.publicKeyData.count, 33) + XCTAssertEqual(auth.privateKeyData.count, 32) + XCTAssertEqual(auth.publicKeyData.toHexString(), testnetAuthPubkeyHex) + + let encryption = try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .encryption, network: .mainnet, storage: storage) + XCTAssertEqual(encryption.publicKeyData.toHexString(), mainnetEncryptionPubkeyHex) + } + + func testDifferentLeavesPurposesAndSubFeaturesGiveDifferentKeys() throws { + let (wallet, storage) = try makeWallet() + + let auth = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .testnet, storage: storage) + let authOtherLeaf = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, + leaf: Data(repeating: 0x6C, count: 32), network: .testnet, storage: storage) + let authOtherIdentity = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: Data(repeating: 0x36, count: 32), + leaf: leaf, network: .testnet, storage: storage) + let enc = try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .encryption, network: .testnet, storage: storage) + let dec = try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .decryption, network: .testnet, storage: storage) + let mainnetAuth = try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .mainnet, storage: storage) + + let all = [auth, authOtherLeaf, authOtherIdentity, enc, dec, mainnetAuth] + .map { $0.publicKeyData } + XCTAssertEqual(Set(all).count, all.count, "every variation derives a distinct key") + } + + func testRejectsMalformedIdsAndAMissingMnemonic() throws { + let (wallet, storage) = try makeWallet() + + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: Data(repeating: 0x35, count: 31), + leaf: leaf, network: .testnet, storage: storage)) + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, + leaf: Data(), network: .testnet, storage: storage)) + + let empty = InMemoryWalletStorage() + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + network: .testnet, storage: empty) + ) { error in + guard case PlatformWalletError.walletOperation(let message) = error else { + return XCTFail("expected walletOperation, got \(error)") + } + XCTAssertTrue(message.contains("no mnemonic stored"), message) + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer.hex new file mode 100644 index 00000000000..6fb1a814b5f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer.hex @@ -0,0 +1 @@ +070011111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222fb03e801000000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex new file mode 100644 index 00000000000..a898d0e311b --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex @@ -0,0 +1 @@ +000001e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f135010000000001010000010121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f7065727469657313000000000000000000000001000000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex new file mode 100644 index 00000000000..9b7463a4f06 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex @@ -0,0 +1 @@ +01000201e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f135010000000001010000010121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f70657274696573130000000000000000000000000000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/identity_update.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/identity_update.hex new file mode 100644 index 00000000000..3d8f3dd950f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/identity_update.hex @@ -0,0 +1 @@ +06001111111111111111111111111111111111111111111111111111111111111111070902001100000200002102020202020202020202020202020202020202020202020202020202020202020241aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa011200000201026666666666666666666666666666666666666666666666666666666666666666002103030303030303030303030303030303030303030303030303030303030303030301fd00000002540be40001fd000001a3185c500041bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0204080203419999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/mixed_batch.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/mixed_batch.hex new file mode 100644 index 00000000000..8b7f67cdf10 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/mixed_batch.hex @@ -0,0 +1 @@ +0201212121212121212121212121212121212121212121212121212121212121212104000000000d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0104706f73744242424242424242424242424242424242424242424242424242424242424242eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01076d6573736167651202686900000300000d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d010770726f66696c6542424242424242424242424242424242424242424242424242424242424242420222222222222222222222222222222222222222222222222222222222222222220102000004034242424242424242424242424242424242424242424242424242424242424242777777777777777777777777777777777777777777777777777777777777777700fa2222222222222222222222222222222222222222222222222222222222222222000000010900000403424242424242424242424242424242424242424242424242424242424242424277777777777777777777777777777777777777777777777777777777777777770064fc05f5e100010200 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift new file mode 100644 index 00000000000..5057c9eb7f2 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift @@ -0,0 +1,182 @@ +import XCTest + +@testable import SwiftDashSDK + +/// `ManagedPlatformWallet.parseStateTransition` decodes any DPP state +/// transition kind and returns a typed summary. The fixtures under +/// `Fixtures/StateTransitions/` are serialized on the Rust side by +/// `parse_state_transition::tests::fixture_bytes_are_pinned_for_the_client_suites` +/// (one hex line each), so the bytes decoded here are exactly the ones the +/// Rust tests decode. +@MainActor +final class ParseStateTransitionTests: XCTestCase { + + private let owner = Data(repeating: 0x21, count: 32) + private let contract = Data(repeating: 0x42, count: 32) + private let token = Data(repeating: 0x77, count: 32) + private let recipient = Data(repeating: 0x22, count: 32) + + /// The parse is a pure FFI call over the bytes; the wallet handle is + /// never touched, so a null handle is fine. + private let wallet = ManagedPlatformWallet( + handle: NULL_HANDLE, walletId: Data(repeating: 0x07, count: 32)) + + private func fixture(_ name: String) throws -> Data { + let url = try XCTUnwrap( + Bundle.module.url( + forResource: name, withExtension: "hex", + subdirectory: "Fixtures/StateTransitions"), + "missing fixture \(name)") + let hex = try String(contentsOf: url, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + return try XCTUnwrap(Data(hexString: hex), "fixture \(name) is not hex") + } + + func testParsesAMixedBatchWithOneEntryPerTransition() throws { + let bytes = try fixture("mixed_batch") + let parsed = try wallet.parseStateTransition(bytes) + + XCTAssertEqual( + parsed.kindName, "DocumentsBatch([Create, Transfer, TokenTransfer, TokenDirectPurchase])") + XCTAssertEqual(parsed.ownerId, owner) + XCTAssertFalse(parsed.isSigned) + XCTAssertEqual(parsed.serialized, bytes, "tagged input decodes as-is") + + guard case .batch(let batch) = parsed.kind else { + return XCTFail("expected .batch, got \(parsed.kind)") + } + XCTAssertEqual(batch.ownerId, owner) + XCTAssertEqual(batch.transitions.count, 4) + + XCTAssertEqual( + batch.transitions[0], + .document( + dataContractId: contract, documentType: "post", + documentId: Data(repeating: 0x0D, count: 32), action: "Create", + amount: nil, recipientId: nil)) + XCTAssertEqual( + batch.transitions[1], + .document( + dataContractId: contract, documentType: "profile", + documentId: Data(repeating: 0x0D, count: 32), action: "Transfer", + amount: nil, recipientId: recipient)) + XCTAssertEqual( + batch.transitions[2], + .token( + dataContractId: contract, tokenId: token, tokenContractPosition: 3, + action: "Transfer", amount: 250, recipientId: recipient)) + XCTAssertEqual( + batch.transitions[3], + .token( + dataContractId: contract, tokenId: token, tokenContractPosition: 3, + action: "DirectPurchase", amount: 100_000_000, recipientId: nil)) + XCTAssertEqual(batch.transitions.map(\.action), ["Create", "Transfer", "Transfer", "DirectPurchase"]) + } + + func testParsesATaglessBatchAndReturnsTaggedBytes() throws { + let tagged = try fixture("mixed_batch") + XCTAssertEqual(tagged.first, 2, "StateTransition::Batch variant tag") + let parsed = try wallet.parseStateTransition(tagged.dropFirst()) + + guard case .batch = parsed.kind else { + return XCTFail("expected .batch, got \(parsed.kind)") + } + XCTAssertEqual(parsed.serialized, tagged, "the variant tag is restored") + } + + func testParsesAnIdentityUpdateWithKeyLimitsAndBounds() throws { + let parsed = try wallet.parseStateTransition(try fixture("identity_update")) + + XCTAssertEqual(parsed.kindName, "IdentityUpdate") + XCTAssertEqual(parsed.ownerId, Data(repeating: 0x11, count: 32)) + XCTAssertTrue(parsed.isSigned) + + guard case .identityUpdate(let update) = parsed.kind else { + return XCTFail("expected .identityUpdate, got \(parsed.kind)") + } + XCTAssertEqual(update.identityId, Data(repeating: 0x11, count: 32)) + XCTAssertEqual(update.disablePublicKeyIds, [4, 8]) + XCTAssertEqual(update.addPublicKeys.count, 2) + + let plain = update.addPublicKeys[0] + XCTAssertEqual(plain.keyId, 17) + XCTAssertNil(plain.totalBudget) + XCTAssertNil(plain.expiresAt) + XCTAssertNil(plain.contractBounds) + + // A DashPay Connect session key: HIGH auth key bound to a contract + // group with a budget and an expiry. + let session = update.addPublicKeys[1] + XCTAssertEqual(session.keyId, 18) + XCTAssertEqual(session.purpose, .authentication) + XCTAssertEqual(session.securityLevel, .high) + XCTAssertEqual(session.keyType, .ecdsaSecp256k1) + XCTAssertEqual(session.pubkeyBytes, Data(repeating: 0x03, count: 33)) + XCTAssertEqual(session.contractBounds, .contractGroup(id: Data(repeating: 0x66, count: 32))) + XCTAssertEqual(session.totalBudget, 10_000_000_000) + XCTAssertEqual(session.expiresAt, 1_800_000_000_000) + } + + func testParsesACreditTransfer() throws { + let parsed = try wallet.parseStateTransition(try fixture("credit_transfer")) + + XCTAssertEqual(parsed.kindName, "IdentityCreditTransfer") + XCTAssertFalse(parsed.isSigned) + XCTAssertEqual( + parsed.kind, + .creditTransfer( + ParsedCreditTransferTransition( + identityId: Data(repeating: 0x11, count: 32), + recipientId: recipient, + amount: 1_000))) + } + + func testParsesDataContractCreateAndUpdate() throws { + for (name, kindName) in [ + ("data_contract_create", "DataContractCreate"), + ("data_contract_update", "DataContractUpdate"), + ] { + let parsed = try wallet.parseStateTransition(try fixture(name)) + XCTAssertEqual(parsed.kindName, kindName) + XCTAssertEqual(parsed.ownerId, owner) + + let contract: ManagedPlatformWallet.ParsedDataContractTransition + switch parsed.kind { + case .dataContractCreate(let c) where name == "data_contract_create": contract = c + case .dataContractUpdate(let c) where name == "data_contract_update": contract = c + default: return XCTFail("unexpected kind \(parsed.kind) for \(name)") + } + XCTAssertEqual(contract.ownerId, owner) + XCTAssertEqual(contract.contractId.count, 32) + // The rs-dpp fixture contract's document types, as the contract + // orders them. + XCTAssertEqual( + contract.documentTypeNames, + [ + "indexedDocument", "niceDocument", "noTimeDocument", + "optionalUniqueIndexedDocument", "prettyDocument", "uniqueDates", + "withByteArrays", + ]) + } + } + + func testRejectsEmptyAndMalformedBytes() { + XCTAssertThrowsError(try wallet.parseStateTransition(Data())) + XCTAssertThrowsError(try wallet.parseStateTransition(Data([0xDE, 0xAD, 0xBE, 0xEF]))) { error in + guard case PlatformWalletError.deserialization = error else { + return XCTFail("expected deserialization, got \(error)") + } + } + } + + /// The Swift projection of a hand-built C struct: an unknown kind tag + /// is an error, not silently `.other`, so a Rust-side addition that + /// the Swift switch does not know about cannot be mis-described. + func testUnknownKindTagIsAnError() { + var ffi = ParsedStateTransitionFFI() + ffi.kind = 77 + XCTAssertThrowsError(try ManagedPlatformWallet.makeParsedStateTransition(from: ffi)) + } +} + +private typealias ParsedCreditTransferTransition = ManagedPlatformWallet.ParsedCreditTransferTransition