diff --git a/Cargo.lock b/Cargo.lock index 84af3bdb6e2..1d582cfc678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6779,6 +6779,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 20c31492e1c..1fe90dd9615 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 with sub-feature 6 (no purpose level), or the half + * of an encryption pair with sub-feature 7: 1 ENCRYPTION, + * 2 DECRYPTION. Any other combination 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 dc0c357cff4..a328b0e9396 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..08ab77cf535 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParser.kt @@ -0,0 +1,432 @@ +package org.dashfoundation.dashsdk.identity + +import java.nio.ByteBuffer +import org.dashfoundation.dashsdk.Sdk +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 + /** + * Whether the typed fields describe every material field of the + * transition. **A row with `complete == false` must not be approved from + * the typed fields alone; the wallet must render [details].** + */ + abstract val complete: Boolean + /** + * The material fields the typed fields do not cover (document data, a + * token config change and who it grants or revokes, an emergency action, + * a price schedule, a distribution type, notes, group-action info), + * rendered for display. Null when [complete]. + */ + abstract val details: String? + /** Credits or tokens the transition moves, when it moves any (a protocol u64). */ + abstract val amount: ULong? + /** + * 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: ULong?, + override val recipientId: ByteArray?, + override val complete: Boolean, + override val details: String?, + ) : 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) && + complete == other.complete && + details == other.details + + 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: ULong?, + override val recipientId: ByteArray?, + /** A `DirectPurchase`'s token count ([amount] is its total agreed price). */ + val tokenCount: ULong?, + override val complete: Boolean, + override val details: String?, + ) : 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) && + tokenCount == other.tokenCount && + complete == other.complete && + details == other.details + + 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: ULong, + ) : 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 decoded transition re-serialized in tagged DPP + * framing; after approval, sign these rather than the input so what was + * shown is what is signed. When [complete] is false, [details] (or the + * batch rows' details) must be shown before approval. 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, + /** + * Percentage added to the processing fee Platform charges (0 = none; + * 65535 is about 656 times the base processing fee). Part of the signed + * bytes: show a non-zero value on the sheet and refuse values above what + * the wallet is willing to pay. + */ + val userFeeIncrease: Int, + /** Whether [kind] shows every material field; computed in Rust. */ + val complete: Boolean, + /** The decoded bytes, tagged. */ + val serialized: ByteArray, + /** + * A single-line dump with every dApp-controlled string quoted: of the whole + * transition for [ParsedStateTransitionKind.Other] and for a data contract + * create / update. Null otherwise. + */ + val details: String?, + 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" } + // This is a handle-free utility, so it may well be the first SDK + // call in the process; load the native library and run dash_sdk_init + // before touching JNI (as TransactionDecoder.decode does), otherwise + // the external fun resolves to UnsatisfiedLinkError. + Sdk.initialize() + val blob = mapNativeErrors { TransactionsNative.parseStateTransition(transitionBytes) } + return decodeBlob(blob) + } + + /** + * [parseBlob], with a blob this decoder cannot read reported as a + * [org.dashfoundation.dashsdk.errors.DashSdkError] like every native + * failure, so callers have one error path. That happens on a Rust/Kotlin + * layout drift and on values the Kotlin types cannot hold (a key limit + * above `Long.MAX_VALUE`, a key id above `Int.MAX_VALUE`). + */ + internal fun decodeBlob(blob: ByteArray): ParsedStateTransition = mapNativeErrors { + try { + parseBlob(blob) + } catch (e: RuntimeException) { + 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 = readString32(buf) + val ownerId = if (readBool(buf)) readId32(buf) else null + val isSigned = readBool(buf) + val userFeeIncrease = buf.short.toInt() and 0xFFFF + val complete = readBool(buf) + val serialized = readBytes32Len(buf) + val details = readString32(buf).ifEmpty { null } + + 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")) { readKeyId(buf) } + 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), readU64(buf)) + 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, userFeeIncrease, complete, serialized, details, kind, + ) + } + + private fun readPublicKey(buf: ByteBuffer): IdentityPubkey { + val keyId = readKeyId(buf) + 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) readLimit(buf, "totalBudget") else null + val expiresAt = if (flags and 2 != 0) readLimit(buf, "expiresAt") 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)) readU64(buf) else null + val recipient = if (readBool(buf)) readId32(buf) else null + val tokenCount = if (readBool(buf)) readU64(buf) else null + require(tokenCount == null) { "malformed parse blob: token count on a document row" } + val complete = readBool(buf) + val details = readString32(buf).ifEmpty { null } + ParsedBatchedTransition.Document( + dataContractId, documentType, documentId, action, amount, recipient, complete, details, + ) + } + FAMILY_TOKEN -> { + val position = buf.short.toInt() and 0xFFFF + val tokenId = readId32(buf) + val amount = if (readBool(buf)) readU64(buf) else null + val recipient = if (readBool(buf)) readId32(buf) else null + val tokenCount = if (readBool(buf)) readU64(buf) else null + val complete = readBool(buf) + val details = readString32(buf).ifEmpty { null } + ParsedBatchedTransition.Token( + dataContractId, tokenId, position, action, amount, recipient, tokenCount, complete, details, + ) + } + 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) + } + + /** + * A protocol u32 key id. Key ids are `Int` in this SDK (see [IdentityPubkey]), + * so one above `Int.MAX_VALUE` is refused rather than read as negative. + */ + private fun readKeyId(buf: ByteBuffer): Int { + val id = buf.int + require(id >= 0) { + "unsupported parsed transition: key id ${id.toUInt()} exceeds ${Int.MAX_VALUE}" + } + return id + } + + /** A protocol u64, big-endian. */ + private fun readU64(buf: ByteBuffer): ULong = buf.long.toULong() + + /** + * A key limit. [IdentityPubkey] carries limits as non-negative `Long`s, so + * a u64 above `Long.MAX_VALUE` is refused rather than read as negative. + */ + private fun readLimit(buf: ByteBuffer, what: String): Long { + val value = readU64(buf) + require(value <= Long.MAX_VALUE.toULong()) { + "unsupported parsed transition: $what $value exceeds ${Long.MAX_VALUE}" + } + return value.toLong() + } + + /** `u32 len + UTF-8 bytes`; an empty string encodes "none". */ + private fun readString32(buf: ByteBuffer): String = String(readBytes32Len(buf), 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..1fe2572e8fc 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,76 @@ 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. [ConnectSubFeature.APP_ENCRYPTION] + * requires a [purpose], which picks the half of its pair; + * [ConnectSubFeature.SESSION_AUTHENTICATION] requires null. Any other + * combination throws. + * + * @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.opWithCleanupOnCancellation( + // withContext's prompt cancellation can discard a completed derive + // before the caller receives the pair it is documented to wipe; scrub + // the private half on that handoff failure. + cleanup = { pair -> pair.first.fill(0) }, + ) { + 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..f06abe2a306 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/StateTransitionParserTest.kt @@ -0,0 +1,290 @@ +package org.dashfoundation.dashsdk.identity + +import org.dashfoundation.dashsdk.errors.DashSdkError +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(0, parsed.userFeeIncrease) + assertTrue(parsed.complete) + assertNull("described kinds carry no common details", parsed.details) + 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(2, update.addPublicKeys.size) + + // The document-type-bound key: its bounds carry a variable-length + // name before the limits flags, so the fields after it are pinned. + val bound = update.addPublicKeys[1] + assertEquals(19, bound.keyId) + assertEquals(KeyPurpose.ENCRYPTION, bound.purpose) + assertEquals(SecurityLevel.MEDIUM, bound.securityLevel) + assertTrue(bound.readOnly) + assertEquals( + ContractBounds.SingleContractDocumentType(ByteArray(32) { 0x44 }, "profile"), + bound.contractBounds, + ) + assertNull(bound.totalBudget) + assertNull(bound.expiresAt) + + val session = update.addPublicKeys[0] + 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 = 250uL, + recipientId = ByteArray(32) { 0x22 }, + tokenCount = null, + complete = true, + details = null, + ), + ), + batch.transitions, + ) + } + + @Test + fun `decodes a mixed document and token batch with completeness and details`() { + val parsed = StateTransitionParser.parseBlob(golden("parsed_mixed_batch_v1.bin")) + + assertEquals("DocumentsBatch([Create, Transfer, TokenDirectPurchase])", parsed.kindName) + assertEquals(7, parsed.userFeeIncrease) + assertFalse(parsed.complete) + assertNull(parsed.details) + + val batch = parsed.kind as ParsedStateTransitionKind.Batch + assertEquals(3, batch.transitions.size) + + // Document create: incomplete, its data rendered in details; the + // fields after the two variable-length strings still line up. + val create = batch.transitions[0] as ParsedBatchedTransition.Document + assertEquals("Create", create.action) + assertEquals("post", create.documentType) + assertArrayEquals(ByteArray(32) { 0x0D }, create.documentId) + assertNull(create.amount) + assertNull(create.recipientId) + assertFalse(create.complete) + assertTrue(create.details!!.contains("\"message\"")) + + assertEquals( + ParsedBatchedTransition.Document( + dataContractId = ByteArray(32) { 0x42 }, + documentType = "profile", + documentId = ByteArray(32) { 0x0D }, + action = "Transfer", + amount = null, + recipientId = ByteArray(32) { 0x22 }, + complete = true, + details = null, + ), + batch.transitions[1], + ) + assertEquals( + ParsedBatchedTransition.Token( + dataContractId = ByteArray(32) { 0x42 }, + tokenId = ByteArray(32) { 0x77 }, + tokenContractPosition = 3, + action = "DirectPurchase", + amount = 100_000_000uL, + recipientId = null, + tokenCount = 100uL, + complete = true, + details = null, + ), + batch.transitions[2], + ) + } + + /** + * [StateTransitionParser.parse] is a handle-free utility and may be the + * first SDK call in a process. Without the native library it must fail + * inside [org.dashfoundation.dashsdk.Sdk.initialize] (the load step) and + * not with an [UnsatisfiedLinkError] from the external fun itself, which + * is what a forgotten initialize looks like. This host JVM has no + * `libdash_sdk_jni.so`, so the load is what fails here; on a device the + * same call path loads the library and parses. + */ + @Test + fun `parse initializes the native library before entering JNI`() { + val error = assertThrows(Throwable::class.java) { + StateTransitionParser.parse(byteArrayOf(0x07, 0x00)) + } + // System.loadLibrary failing is an UnsatisfiedLinkError whose message + // names the library; a missing native method names the method. + assertTrue( + "expected the library load to fail, got ${error::class.simpleName}: ${error.message}", + error is UnsatisfiedLinkError && error.message?.contains("dash_sdk_jni") == true, + ) + assertFalse( + "parse reached the external fun before loading the library", + error.message?.contains("parseStateTransition") == true, + ) + } + + /** + * The price ceiling and token count are protocol u64s; `u64::MAX` must come + * through as the full unsigned value, not as a negative amount a spending + * limit check would let through. + */ + @Test + fun `keeps a u64 max purchase price unsigned`() { + val parsed = StateTransitionParser.parseBlob(golden("parsed_max_price_purchase_v1.bin")) + val batch = parsed.kind as ParsedStateTransitionKind.Batch + val purchase = batch.transitions.single() as ParsedBatchedTransition.Token + + assertEquals("DirectPurchase", purchase.action) + assertEquals(ULong.MAX_VALUE, purchase.amount) + assertEquals(ULong.MAX_VALUE, purchase.tokenCount) + assertEquals("18446744073709551615", purchase.amount.toString()) + } + + /** + * [IdentityPubkey] carries key limits as non-negative `Long`s; a budget + * above `Long.MAX_VALUE` is refused with a message rather than surfacing + * as a negative value. + */ + @Test + fun `refuses a key budget above Long MAX_VALUE`() { + val golden = golden("parsed_identity_update_v1.bin") + // The golden's limited key carries `total_budget = 10_000_000_000` + // (0x00000002540be400), once inside the serialized transition and + // once in the parsed key that follows it; set the parsed copy's high bit. + val budget = byteArrayOf(0, 0, 0, 2, 0x54, 0x0b, 0xe4.toByte(), 0) + val at = (0..golden.size - budget.size).last { i -> + (budget.indices).all { golden[i + it] == budget[it] } + } + val patched = golden.copyOf().also { it[at] = 0x80.toByte() } + + val error = assertThrows(IllegalArgumentException::class.java) { + StateTransitionParser.parseBlob(patched) + } + assertTrue(error.message!!.contains("totalBudget")) + + // `parse` reports the same refusal through the public error hierarchy. + val mapped = assertThrows(DashSdkError.InternalError::class.java) { + StateTransitionParser.decodeBlob(patched) + } + assertTrue(mapped.message!!.contains("totalBudget")) + } + + /** + * Disabled key ids are protocol u32s; one above `Int.MAX_VALUE` is refused + * rather than surfacing as a negative id. + */ + @Test + fun `refuses a disabled key id above Int MAX_VALUE`() { + // kind 1, u32 name, no owner, unsigned, fee 0, complete, empty + // serialized, empty details, identity id, no added keys, one disabled + // key 0xFFFFFFFF. + val name = "IdentityUpdate".toByteArray() + val blob = byteArrayOf(1, 0, 0, 0, name.size.toByte()) + name + + byteArrayOf(0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0) + + ByteArray(32) { 0x11 } + + byteArrayOf(0, 0, 0, 0) + + byteArrayOf(0, 0, 0, 1, -1, -1, -1, -1) + + val error = assertThrows(IllegalArgumentException::class.java) { + StateTransitionParser.parseBlob(blob) + } + assertTrue(error.message!!.contains("4294967295")) + } + + @Test + fun `decodes the other kind with the common fields and details`() { + // kind 255, name "MasternodeVote", no owner, signed, fee increase 3, + // incomplete, empty serialized, details "vote". + val name = "MasternodeVote".toByteArray() + val details = "vote".toByteArray() + val blob = byteArrayOf(0xFF.toByte(), 0, 0, 0, name.size.toByte()) + name + + byteArrayOf(0, 1, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, details.size.toByte()) + details + val parsed = StateTransitionParser.parseBlob(blob) + + assertEquals("MasternodeVote", parsed.kindName) + assertNull(parsed.ownerId) + assertTrue(parsed.isSigned) + assertEquals(3, parsed.userFeeIncrease) + assertFalse(parsed.complete) + assertEquals(0, parsed.serialized.size) + assertEquals("vote", parsed.details) + 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() + // kind 77, u32 name, no owner, unsigned, fee 0, incomplete, empty + // serialized, empty details. + val blob = byteArrayOf(77, 0, 0, 0, name.size.toByte()) + name + + byteArrayOf(0, 0, 0, 0, 0, 0, 0, 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..07cd5cba17c 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_max_price_purchase_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_max_price_purchase_v1.bin new file mode 100644 index 00000000000..262d769210f Binary files /dev/null and b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_max_price_purchase_v1.bin differ diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_v1.bin new file mode 100644 index 00000000000..68a6472b2da Binary files /dev/null and b/packages/kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_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..64a2ede1c46 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..0e726223610 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs @@ -0,0 +1,476 @@ +//! 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 key_wallet::bip32::ApplicationKeyPurpose; +use key_wallet::dip9::{ + FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION, + FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION, +}; +use platform_wallet::wallet::identity::network::{derive_connect_keypair_from_master, ConnectKey}; + +use crate::error::*; +use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; +use crate::types::{read_identifier, FFINetwork, Network}; +use crate::{check_ptr, unwrap_result_or_return}; +use rs_sdk_ffi::MnemonicResolverHandle; + +/// `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 = 6; + +/// `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 = 7; + +/// `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; + +// cbindgen only exports literal constants; these pin the literals above to +// key-wallet's. +const _: () = assert!( + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION + == FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION +); +const _: () = assert!( + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION + == FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION +); +const _: () = assert!(CONNECT_KEY_PURPOSE_ENCRYPTION == ApplicationKeyPurpose::Encryption as u32); +const _: () = assert!(CONNECT_KEY_PURPOSE_DECRYPTION == ApplicationKeyPurpose::Decryption as u32); + +/// 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 a DashPay Connect key from the mnemonic the resolver returns for +/// `wallet_id_bytes`, at the DIP-13 application paths key-wallet builds. +/// +/// - `sub_feature` `6` with `purpose` `0`: the session authentication key; +/// `leaf_bytes` is the connect request id. +/// - `sub_feature` `7` with `purpose` `1` (ENCRYPTION) or `2` (DECRYPTION): +/// one half of the app encryption pair; `leaf_bytes` is the bound contract +/// id. +/// +/// Any other combination is refused with `ErrorInvalidParameter`. +/// `network` picks the coin type (`5'` mainnet, `1'` otherwise). +/// +/// 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 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 key = match (sub_feature, purpose) { + (CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, 0) => ConnectKey::SessionAuthentication { + identity_id, + request_id: leaf, + }, + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, CONNECT_KEY_PURPOSE_ENCRYPTION) => { + ConnectKey::AppEncryption { + identity_id, + contract_id: leaf, + purpose: ApplicationKeyPurpose::Encryption, + } + } + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, CONNECT_KEY_PURPOSE_DECRYPTION) => { + ConnectKey::AppEncryption { + identity_id, + contract_id: leaf, + purpose: ApplicationKeyPurpose::Decryption, + } + } + (sub_feature, purpose) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "sub_feature {sub_feature} with purpose {purpose} is not a DashPay Connect \ + key (6 with purpose 0, or 7 with purpose 1 or 2)" + ), + ); + } + }; + + let kw_network: Network = network.into(); + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .expect("from_raw_parts(_, 32) always yields exactly 32 bytes"); + let mut master = unwrap_result_or_return!(resolve_master_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + kw_network, + )); + + let derived = derive_connect_keypair_from_master(&master, kw_network, key); + // Dropping `master` zeroizes it too; erase now so the scalar does not + // outlive the derive. + master.private_key.non_secure_erase(); + let derived = unwrap_result_or_return!(derived); + + (*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::bip32::ExtendedPrivKey; + use key_wallet::mnemonic::Mnemonic; + use rs_sdk_ffi::mnemonic_resolver_result; + use rs_sdk_ffi::{dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy}; + use std::ffi::c_void; + use std::ffi::CStr; + use std::os::raw::c_char; + + /// 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, + ConnectKey::SessionAuthentication { + identity_id: Identifier::from(IDENTITY), + request_id: LEAF, + }, + ) + .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. + #[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 = 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, 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 session authentication with no purpose, and app encryption with + /// ENCRYPTION or DECRYPTION, derive; anything else is refused before the + /// seed is resolved. + #[test] + fn other_sub_feature_and_purpose_combinations_are_invalid_parameters() { + for (sub_feature, purpose) in [ + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, 3), + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, 0), + (CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, 1), + (5, 0), + ] { + 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(), + sub_feature, + IDENTITY.as_ptr(), + LEAF.as_ptr(), + purpose, + &mut out, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "sub_feature {sub_feature} purpose {purpose}" + ); + let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); + assert!( + message.contains(&format!("sub_feature {sub_feature} with purpose {purpose}")), + "{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 e9cdf4bce92..1840c3be90c 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,63 @@ 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)); + } + #[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..32b7de4c8bb 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -12,11 +12,20 @@ use std::slice; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::KeyID; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; -use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; -use dpp::state_transition::StateTransition; +use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dpp::state_transition::public_key_in_creation::accessors::{ + IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV1Getters, +}; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::state_transition::{StateTransition, StateTransitionType}; +use platform_wallet::wallet::identity::network::{ + check_document_type_name, decode_state_transition, +}; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -29,9 +38,6 @@ use crate::runtime::block_on_worker; use crate::types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; -/// Positional bincode variant tag of `StateTransition::IdentityUpdate`. -pub(crate) const IDENTITY_UPDATE_VARIANT_TAG: u8 = 6; - /// Owned C representation of one public key carried by a parsed /// `IdentityUpdateTransition`. /// @@ -52,6 +58,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 @@ -79,31 +96,26 @@ impl Default for ParsedIdentityUpdateFFI { fn parse_identity_update_transition_bytes( bytes: &[u8], -) -> Result< - dpp::state_transition::identity_update_transition::IdentityUpdateTransition, - PlatformWalletFFIResult, -> { - // Tolerates Yappr's tagless framing next to standard tagged bytes — see - // the shared helper for the ordering heuristic. - let state_transition = - crate::parse_state_transition::deserialize_transition_with_flexible_framing( - bytes, - &[(IDENTITY_UPDATE_VARIANT_TAG, "IdentityUpdate")], - )?; +) -> Result { + // Tolerates Yappr's untagged identity updates next to standard tagged bytes. + let (state_transition, _tagged_bytes) = + decode_state_transition(bytes, &[StateTransitionType::IdentityUpdate]) + .map_err(crate::parse_state_transition::deserialization_error)?; match state_transition { StateTransition::IdentityUpdate(identity_update) => Ok(identity_update), other => Err(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("Expected IdentityUpdateTransition, got {other:?}"), + format!("Expected IdentityUpdateTransition, got {}", other.name()), )), } } /// Reporting a narrower bound than the transition declares would let the user /// approve a broader scope than the one they were shown, so a document type -/// that cannot cross the FFI as a C string is an error rather than a fallback -/// to the contract-only bound. +/// that cannot be shown as is is an error rather than a fallback to the +/// contract-only bound. The name is the approval sheet's own field, so it must +/// pass the same consensus-pattern check the state transition summary applies. fn encode_contract_bounds( bounds: Option<&ContractBounds>, ) -> Result<(u8, [u8; 32], *mut c_char), PlatformWalletFFIResult> { @@ -112,16 +124,22 @@ fn encode_contract_bounds( Some(ContractBounds::SingleContractDocumentType { id, document_type_name, - }) => match CString::new(document_type_name.as_str()) { - Ok(value) => Ok((2u8, id.to_buffer(), value.into_raw())), - Err(error) => Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "Contract-bounds document type name cannot be represented as a C string: \ - {error}" - ), - )), - }, + }) => { + // The consensus pattern excludes NUL, so a name that passes it is + // always a valid C string. + let value = check_document_type_name(document_type_name) + .map_err(|error| error.to_string()) + .and_then(|()| { + CString::new(document_type_name.as_str()).map_err(|error| error.to_string()) + }) + .map_err(|error| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("Contract-bounds document type: {error}"), + ) + })?; + Ok((2u8, id.to_buffer(), value.into_raw())) + } Some(ContractBounds::ContractGroup { id }) => Ok((3u8, id.to_buffer(), ptr::null_mut())), None => Ok((0u8, [0u8; 32], ptr::null_mut())), } @@ -151,11 +169,12 @@ unsafe fn free_parsed_public_keys(keys: &mut [ParsedIdentityUpdatePublicKeyFFI]) } pub(crate) fn project_parsed_identity_update( - transition: &dpp::state_transition::identity_update_transition::IdentityUpdateTransition, + identity_id: &Identifier, + public_keys_to_add: &[IdentityPublicKeyInCreation], + public_key_ids_to_disable: &[KeyID], ) -> Result { - let identity_id = transition.identity_id().to_buffer(); + let identity_id = identity_id.to_buffer(); - let public_keys_to_add = transition.public_keys_to_add(); let mut add_public_keys_vec: Vec = Vec::with_capacity(public_keys_to_add.len()); @@ -188,6 +207,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(), }); } @@ -199,7 +222,7 @@ pub(crate) fn project_parsed_identity_update( as *mut ParsedIdentityUpdatePublicKeyFFI }; - let disable_public_key_ids_vec = transition.public_key_ids_to_disable().to_vec(); + let disable_public_key_ids_vec = public_key_ids_to_disable.to_vec(); let disable_public_key_ids_count = disable_public_key_ids_vec.len(); let disable_public_key_ids = if disable_public_key_ids_count == 0 { ptr::null_mut() @@ -219,9 +242,9 @@ pub(crate) fn project_parsed_identity_update( /// Deserializes a raw `IdentityUpdateTransition` (as carried by a DashConnect /// `dash-st:` QR) into its inspectable parts. /// -/// Accepts both normal tagged DPP state-transition bytes and Yappr's tagless -/// framing, where the positional bincode enum variant tag `6` has to be -/// prepended before deserialization. +/// Accepts both normal tagged DPP state-transition bytes and an identity update +/// serialized without the `StateTransition` variant tag (Yappr's framing); +/// trailing bytes are refused. /// /// Does NOT sign and does NOT broadcast — the caller validates the result and /// rebuilds the transition through the normal signing path. @@ -238,7 +261,11 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition( let bytes = slice::from_raw_parts(transition_bytes, transition_len); let transition = unwrap_result_or_return!(parse_identity_update_transition_bytes(bytes)); - *out = unwrap_result_or_return!(project_parsed_identity_update(&transition)); + *out = unwrap_result_or_return!(project_parsed_identity_update( + &transition.identity_id(), + transition.public_keys_to_add(), + transition.public_key_ids_to_disable(), + )); PlatformWalletFFIResult::ok() } @@ -538,27 +565,6 @@ mod tests { unsafe { platform_wallet_parse_identity_update_transition_free(&mut out) }; } - #[test] - fn parses_tagless_framing_by_prepending_the_variant_tag() { - let tagged = fixture_transition_bytes(); - let tagless = tagged[1..].to_vec(); - let mut out = ParsedIdentityUpdateFFI::default(); - - let result = unsafe { - platform_wallet_parse_identity_update_transition( - tagless.as_ptr(), - tagless.len(), - &mut out, - ) - }; - - assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!(out.identity_id, [0x11; 32]); - assert_eq!(out.add_public_keys_count, 2); - - unsafe { platform_wallet_parse_identity_update_transition_free(&mut out) }; - } - #[test] fn rejects_malformed_identity_update_transition_bytes() { let bytes = [0xde, 0xad, 0xbe, 0xef]; @@ -635,7 +641,11 @@ mod tests { // `ParsedIdentityUpdateFFI` is a raw-pointer C struct with no `Debug`, // so the success case is rejected by hand rather than with `expect_err`. - let error = match project_parsed_identity_update(&transition) { + let error = match project_parsed_identity_update( + &transition.identity_id(), + transition.public_keys_to_add(), + transition.public_key_ids_to_disable(), + ) { Ok(_) => panic!("a document type with an interior NUL must not be narrowed"), Err(error) => error, }; @@ -646,6 +656,52 @@ mod tests { ); } + /// The legacy parser shows a document-type bound in its own field, so a + /// name outside the consensus pattern is refused through the public entry + /// point, as the state transition summary refuses it. + #[test] + fn rejects_contract_bounds_document_type_outside_the_consensus_pattern() { + for name in ["a\nb", "post\u{202e}", "", &"x".repeat(65)] { + let transition: StateTransition = IdentityUpdateTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreationV0 { + id: 17, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + read_only: false, + data: BinaryData::new(vec![0x02; 33]), + signature: BinaryData::new(vec![0xaa; 65]), + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([0x44; 32]), + document_type_name: name.to_string(), + }), + } + .into()], + ..Default::default() + } + .into(); + let bytes = transition.serialize_to_bytes().unwrap(); + let mut out = ParsedIdentityUpdateFFI::default(); + + let result = unsafe { + platform_wallet_parse_identity_update_transition( + bytes.as_ptr(), + bytes.len(), + &mut out, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "{name:?}" + ); + assert!(out.add_public_keys.is_null(), "{name:?}"); + } + } + #[test] fn rejects_truncated_identity_update_transition_bytes() { let mut bytes = fixture_transition_bytes(); 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..d639f4d3b04 100644 --- a/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs +++ b/packages/rs-platform-wallet-ffi/src/parse_state_transition.rs @@ -1,299 +1,524 @@ -//! FFI parser for raw DPP state transitions handed to the wallet by a dApp -//! (DashConnect `dash-st:` links / QRs). +//! FFI projection of [`summarize_state_transition`] for raw DPP state +//! transitions handed to the wallet by a dApp (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. +//! Decoding and deciding what the user has to see live in `platform-wallet` +//! ([`StateTransitionSummary`]); this module copies the summary into owned C +//! structs: //! -//! 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. - -use std::borrow::Cow; +//! - `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: `OTHER`, with the whole decoded transition in `details`. +//! +//! **Completeness contract.** A batched row whose typed fields do not cover +//! every material field carries `complete == false` and renders the rest in +//! its `details`; a wallet must not approve such a row from the typed summary +//! alone. The `OTHER` kind likewise carries the whole decoded transition in +//! the common `details`. +//! +//! This module does not sign and does not broadcast. + +use std::ffi::CString; +use std::os::raw::c_char; +use std::ptr; use std::slice; -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::BatchedTransitionRef; -use dpp::state_transition::batch_transition::token_base_transition::token_base_transition_accessors::TokenBaseTransitionAccessors; -use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; -use dpp::state_transition::batch_transition::token_direct_purchase_transition::v0::v0_methods::TokenDirectPurchaseTransitionV0Methods; -use dpp::state_transition::batch_transition::BatchTransition; -use dpp::state_transition::{StateTransition, StateTransitionOwned}; +use dpp::prelude::Identifier; +use platform_wallet::error::PlatformWalletError; +use platform_wallet::wallet::identity::network::{ + summarize_state_transition, BatchedTransitionSummary, BatchedTransitionTarget, + DataContractSummary, StateTransitionSummary, StateTransitionSummaryKind, +}; use crate::check_ptr; use crate::error::*; use crate::identity_update::{ platform_wallet_parse_identity_update_transition_free, project_parsed_identity_update, - ParsedIdentityUpdateFFI, IDENTITY_UPDATE_VARIANT_TAG, + ParsedIdentityUpdateFFI, }; use crate::unwrap_result_or_return; -/// Positional bincode variant tag of `StateTransition::Batch`. -pub(crate) const BATCH_VARIANT_TAG: u8 = 2; - /// `ParsedStateTransitionFFI::kind`: nothing was parsed (default state). 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; - -/// Variant tags tried when the payload appears to use a tagless framing. -/// `IdentityUpdate` first: it is the framing Yappr has actually been observed -/// to send tagless; `Batch` payloads have so far arrived properly tagged. -const TAGLESS_FRAMING_CANDIDATES: &[(u8, &str)] = &[ - (IDENTITY_UPDATE_VARIANT_TAG, "IdentityUpdate"), - (BATCH_VARIANT_TAG, "Batch"), -]; - -/// Owned C representation of the inspectable parts of a token direct -/// purchase carried by a parsed `BatchTransition`. -/// -/// Plain old data — no heap allocations, so nothing beyond the containing -/// [`ParsedStateTransitionFFI`] needs freeing. -/// -/// 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). +/// `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 without a describer; the common +/// fields are populated, with the whole transition in `details`. +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; + +/// One transition inside a parsed `BatchTransition`; the fields mirror +/// `BatchedTransitionSummary`. `action`, `document_type` (null for token rows) +/// and `details` are owned, NUL-terminated, and released by +/// [`platform_wallet_parse_state_transition_free`]. A row with +/// `complete == false` must not be approved without rendering `details`. #[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. + /// 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], + /// Whether a direct purchase's token count is set. + pub has_token_count: bool, + /// Tokens bought by a `DirectPurchase`. pub token_count: u64, - /// Credits the owner would agree to pay in total. - pub total_agreed_price: u64, + /// Whether the typed fields describe every material field. See the + /// struct doc. + pub complete: bool, + /// Rendering of the material fields the typed fields do not cover, when + /// `complete` is false; null otherwise. Owned, NUL-terminated. + pub details: *mut c_char, } -/// 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], + has_token_count: false, + token_count: 0, + complete: true, + details: ptr::null_mut(), + } + } +} + +/// 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, + /// Percentage added to the processing fee; part of the signed bytes. + pub user_fee_increase: u16, + /// Whether the typed payload shows every material field + /// (`StateTransitionSummary::is_complete`); when false, render `details` + /// (or the rows' `details`) before approval. + pub complete: bool, + /// The decoded transition re-serialized, tagged: what a caller signs + /// after approval. + pub serialized: *mut u8, + pub serialized_len: usize, + /// `OTHER` and data contract create / update: single-line `Debug` dump of + /// the whole transition. Null otherwise. Owned, NUL-terminated. + pub details: *mut c_char, /// 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, } -/// 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. +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, + user_fee_increase: 0, + complete: false, + serialized: ptr::null_mut(), + serialized_len: 0, + details: ptr::null_mut(), + identity_update: ParsedIdentityUpdateFFI::default(), + batch: ParsedBatchFFI::default(), + credit_transfer: ParsedCreditTransferFFI::default(), + data_contract: ParsedDataContractFFI::default(), + } + } +} + +/// Bytes the summary could not decode. +pub(crate) fn deserialization_error(error: PlatformWalletError) -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorDeserialization, + error.to_string(), + ) +} + +/// 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. /// -/// 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. -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)); - - 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); - prefixed.push(*tag); - prefixed.extend_from_slice(bytes); - ( - Cow::Owned(prefixed), - format!("{name} variant tag prepended"), +/// 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!("{what} cannot be represented as a C string: {error}"), ) - }); + }) +} - 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); +fn optional_c_string( + value: Option<&str>, + what: &str, +) -> Result, PlatformWalletFFIResult> { + value.map(|value| owned_c_string(value, what)).transpose() +} + +unsafe fn free_c_string(ptr: &mut *mut c_char) { + if !ptr.is_null() { + drop(CString::from_raw(*ptr)); + *ptr = ptr::null_mut(); } +} - 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), - Err(error) => failures.push(format!("{label}: {error}")), +fn project_batched_transition( + row: &BatchedTransitionSummary, +) -> Result { + let action = owned_c_string(&row.action, "Batched transition action")?; + let details = optional_c_string(row.details.as_deref(), "Batched transition details")?; + let mut out = ParsedBatchedTransitionFFI { + data_contract_id: row.data_contract_id.to_buffer(), + has_amount: row.amount.is_some(), + amount: row.amount.unwrap_or_default(), + has_recipient: row.recipient_id.is_some(), + recipient_id: row + .recipient_id + .map(|id| id.to_buffer()) + .unwrap_or_default(), + has_token_count: row.token_count.is_some(), + token_count: row.token_count.unwrap_or_default(), + complete: row.is_complete(), + ..ParsedBatchedTransitionFFI::default() + }; + match &row.target { + BatchedTransitionTarget::Document { + document_type, + document_id, + } => { + let document_type = + owned_c_string(document_type, "Batched document transition document type")?; + out.family = PARSED_BATCHED_TRANSITION_FAMILY_DOCUMENT; + out.document_type = document_type.into_raw(); + out.document_id = document_id.to_buffer(); + } + BatchedTransitionTarget::Token { + token_id, + token_contract_position, + } => { + out.family = PARSED_BATCHED_TRANSITION_FAMILY_TOKEN; + out.token_id = token_id.to_buffer(); + out.token_contract_position = *token_contract_position; } } + out.action = action.into_raw(); + out.details = details.map(CString::into_raw).unwrap_or(ptr::null_mut()); + Ok(out) +} - Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorDeserialization, - format!( - "Failed to deserialize state transition in any supported framing ({})", - failures.join("; ") - ), - )) +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); + free_c_string(&mut row.details); + } } -/// 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. -/// -/// `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( - 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})" - ), - )); +fn project_parsed_batch( + owner_id: &Identifier, + transitions: &[BatchedTransitionSummary], +) -> Result { + let mut rows: Vec = Vec::with_capacity(transitions.len()); + for transition in transitions { + match project_batched_transition(transition) { + 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); + } + } } - 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}" - ), - )); + 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 }; - let base = purchase.base(); + Ok(ParsedBatchFFI { + owner_id: owner_id.to_buffer(), + transitions, + transitions_count, + }) +} - // 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(), - )); - } - - Ok(ParsedTokenDirectPurchaseFFI { - 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(), +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: &DataContractSummary, +) -> 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 names = contract + .document_type_names + .iter() + .map(|name| owned_c_string(name, "Data contract document type name")) + .collect::, _>>()?; + + 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.contract_id.to_buffer(), + owner_id: contract.owner_id.to_buffer(), + document_type_names, + document_type_names_count, }) } -/// 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. -/// -/// 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. -/// -/// 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. -/// -/// 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`). +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(); +} + +/// Copies a summary into the C struct. Everything allocated is owned by the +/// result on success; on failure nothing is left allocated. +fn project_parsed_state_transition( + summary: StateTransitionSummary, +) -> Result { + // Kept as owned `CString`s until nothing below can fail, so an error + // return drops them. + let kind_name = owned_c_string(&summary.kind_name, "State transition kind name")?; + let details = match &summary.kind { + StateTransitionSummaryKind::Other { details } + | StateTransitionSummaryKind::DataContractCreate(DataContractSummary { details, .. }) + | StateTransitionSummaryKind::DataContractUpdate(DataContractSummary { details, .. }) => { + Some(owned_c_string(details, "State transition details")?) + } + _ => None, + }; + + // Each arm stores at most one payload, and only on success, so an error + // leaves nothing to free. + let mut out = ParsedStateTransitionFFI::default(); + out.kind = match &summary.kind { + StateTransitionSummaryKind::IdentityUpdate { + identity_id, + add_public_keys, + disable_public_key_ids, + } => { + out.identity_update = project_parsed_identity_update( + identity_id, + add_public_keys, + disable_public_key_ids, + )?; + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE + } + StateTransitionSummaryKind::Batch { + owner_id, + transitions, + } => { + out.batch = project_parsed_batch(owner_id, transitions)?; + PARSED_STATE_TRANSITION_KIND_BATCH + } + StateTransitionSummaryKind::CreditTransfer { + identity_id, + recipient_id, + amount, + } => { + out.credit_transfer = ParsedCreditTransferFFI { + identity_id: identity_id.to_buffer(), + recipient_id: recipient_id.to_buffer(), + amount: *amount, + }; + PARSED_STATE_TRANSITION_KIND_CREDIT_TRANSFER + } + StateTransitionSummaryKind::DataContractCreate(contract) => { + out.data_contract = project_parsed_data_contract(contract)?; + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE + } + StateTransitionSummaryKind::DataContractUpdate(contract) => { + out.data_contract = project_parsed_data_contract(contract)?; + PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE + } + StateTransitionSummaryKind::Other { .. } => PARSED_STATE_TRANSITION_KIND_OTHER, + }; + + out.complete = summary.is_complete(); + out.kind_name = kind_name.into_raw(); + out.has_owner_id = summary.owner_id.is_some(); + out.owner_id = summary + .owner_id + .map(|id| id.to_buffer()) + .unwrap_or_default(); + out.is_signed = summary.is_signed; + out.user_fee_increase = summary.user_fee_increase; + out.serialized_len = summary.serialized.len(); + out.serialized = Box::into_raw(summary.serialized.into_boxed_slice()) as *mut u8; + out.details = details.map(CString::into_raw).unwrap_or(ptr::null_mut()); + Ok(out) +} + +/// Decodes a raw DPP state transition (a DashConnect `dash-st:` link / QR or a +/// DashPay Connect `sign` request) and copies [`summarize_state_transition`]'s +/// summary into `out`. Accepts tagged bytes and an identity update or batch +/// serialized without the `StateTransition` variant tag; trailing bytes are +/// refused. Does NOT sign and does NOT broadcast. #[no_mangle] pub unsafe extern "C" fn platform_wallet_parse_state_transition( transition_bytes: *const u8, transition_len: usize, out: *mut ParsedStateTransitionFFI, ) -> PlatformWalletFFIResult { - check_ptr!(transition_bytes); check_ptr!(out); - *out = ParsedStateTransitionFFI::default(); + check_ptr!(transition_bytes); 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, - )); - - 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() - ), - ); - } - } + let summary = + unwrap_result_or_return!(summarize_state_transition(bytes).map_err(deserialization_error)); + + *out = unwrap_result_or_return!(project_parsed_state_transition(summary)); 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, @@ -304,35 +529,71 @@ 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_batch(&mut parsed.batch); + free_parsed_data_contract(&mut parsed.data_contract); + free_c_string(&mut parsed.kind_name); + free_c_string(&mut parsed.details); + 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::state_transition::batch_transition::batched_transition::{ + DocumentTransition, TokenTransition, + }; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::StateTransition; + 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 +601,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 +671,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 +710,82 @@ mod tests { .expect("fixture identity update serializes") } + fn credit_transfer_bytes() -> Vec { + credit_transfer_bytes_with_fee_increase(0) + } + + fn credit_transfer_bytes_with_fee_increase(user_fee_increase: u16) -> Vec { + StateTransition::IdentityCreditTransfer( + IdentityCreditTransferTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from(RECIPIENT), + amount: 1_000, + nonce: 1, + user_fee_increase, + 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,56 +794,133 @@ 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); + // The document data has no typed projection, so the row is + // incomplete and renders it. + assert!(!create.complete); + let create_details = unsafe { c_str(create.details) }; + assert!(create_details.contains("\"message\""), "{create_details}"); + assert!(create_details.contains("\"hi\""), "{create_details}"); + + 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); + assert!(transfer.complete); + assert!(transfer.details.is_null()); + + 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); + assert!(purchase.has_token_count); + assert_eq!(purchase.token_count, 100); + assert!(purchase.complete); + assert_eq!(out.user_fee_increase, 1); + assert!(!out.complete, "the create row renders its data"); + + // 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 parses_a_tagless_token_purchase_by_prepending_the_batch_tag() { - let tagged = batch_transition_bytes(vec![direct_purchase()]); - assert_eq!( - tagged[0], BATCH_VARIANT_TAG, - "StateTransition::Batch variant tag drifted" - ); - let tagless = tagged[1..].to_vec(); - - let (result, mut out) = parse(&tagless); - + 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_TOKEN_DIRECT_PURCHASE); - assert_eq!(out.token_direct_purchase.token_count, 100); - + 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_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,115 +928,279 @@ 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); + assert!(!out.complete); + assert!(unsafe { c_str(out.details) }.contains("niceDocument")); + + 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()); + // The structured dump names the vote's material fields. + let details = unsafe { c_str(out.details) }; + assert!(details.contains("parentNameAndLabel"), "{details}"); + assert!(details.contains("Abstain"), "{details}"); + + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + assert!(out.kind_name.is_null()); + assert!(out.details.is_null()); + } + + /// An `OTHER` kind the wallet is likely to be asked to sign; shared with + /// the client suites as the `credit_withdrawal` fixture. + fn credit_withdrawal_bytes() -> Vec { + use dpp::identity::core_script::CoreScript; + use dpp::state_transition::identity_credit_withdrawal_transition::v1::IdentityCreditWithdrawalTransitionV1; + use dpp::withdrawal::Pooling; + + StateTransition::IdentityCreditWithdrawal( + IdentityCreditWithdrawalTransitionV1 { + identity_id: Identifier::from([0x11; 32]), + amount: 123_456_789, + core_fee_per_byte: 7, + pooling: Pooling::Never, + output_script: Some(CoreScript::from_bytes(vec![0x76, 0xa9, 0x14, 0xAB])), + nonce: 3, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(), + ) + .serialize_to_bytes() + .expect("fixture withdrawal serializes") + } + /// Bytes left over after a complete transition are refused through the + /// public parser, since the summary would not show whatever they carry. + /// (Framing ambiguity and prefix decodes are covered where the decoding + /// lives, in `platform_wallet`'s `state_transition_summary` tests.) + #[test] + fn trailing_bytes_after_a_complete_transition_are_refused() { + let (bytes, _, _) = data_contract_create_bytes(); + let (result, mut out) = parse(&bytes); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE); + unsafe { platform_wallet_parse_state_transition_free(&mut out) }; + + let mut padded = bytes; + padded.push(0); + let (result, out) = parse(&padded); assert_eq!( result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter + PlatformWalletFFIResultCode::ErrorDeserialization ); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert!(out.serialized.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 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, - }, + 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![], )), - )]); - let (result, out) = parse(&bytes); - + hex::encode(credit_transfer_bytes()), + hex::encode(contract_create), + hex::encode(contract_update), + hex::encode(credit_transfer_bytes_with_fee_increase(65_535)), + hex::encode(credit_withdrawal_bytes()), + ]; + let expected = [ + FIXTURE_IDENTITY_UPDATE_HEX, + FIXTURE_MIXED_BATCH_HEX, + FIXTURE_CREDIT_TRANSFER_HEX, + FIXTURE_DATA_CONTRACT_CREATE_HEX, + FIXTURE_DATA_CONTRACT_UPDATE_HEX, + FIXTURE_CREDIT_TRANSFER_MAX_FEE_HEX, + FIXTURE_CREDIT_WITHDRAWAL_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"); + const FIXTURE_CREDIT_TRANSFER_MAX_FEE_HEX: &str = client_fixture!("credit_transfer_max_fee"); + const FIXTURE_CREDIT_WITHDRAWAL_HEX: &str = client_fixture!("credit_withdrawal"); + #[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()); } + /// A name that is not a valid document type name is refused before the + /// FFI projects anything. #[test] - fn rejects_malformed_state_transition_bytes() { - let bytes = [0xde, 0xad, 0xbe, 0xef]; + fn rejects_an_invalid_document_type_name() { + let bytes = batch_transition_bytes( + vec![document_create("post"), document_create("pro\0file")], + vec![], + ); let (result, out) = parse(&bytes); assert_eq!( @@ -568,6 +1208,36 @@ mod tests { PlatformWalletFFIResultCode::ErrorDeserialization ); assert_eq!(out.kind, PARSED_STATE_TRANSITION_KIND_NONE); + assert!(out.batch.transitions.is_null()); + } + + /// When a later row cannot cross the FFI, the rows already projected are + /// released rather than leaked. + #[test] + fn a_row_that_cannot_cross_the_ffi_releases_the_projected_rows() { + use platform_wallet::wallet::identity::network::BatchedTransitionSummary; + + let row = |action: &str| BatchedTransitionSummary { + data_contract_id: Identifier::from(CONTRACT), + action: action.to_string(), + target: BatchedTransitionTarget::Document { + document_type: "post".to_string(), + document_id: Identifier::from([0x0D; 32]), + }, + amount: None, + recipient_id: None, + token_count: None, + details: Some("data".to_string()), + }; + let result = + project_parsed_batch(&Identifier::from(OWNER), &[row("Create"), row("Cre\0ate")]); + let Err(error) = result else { + panic!("an interior NUL must not cross the FFI"); + }; + assert_eq!( + error.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); } #[test] @@ -577,5 +1247,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/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 5d62f66db0e..2810606f8c0 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -93,6 +93,8 @@ required-features = ["shielded"] [dev-dependencies] tempfile = "3.27.0" +# `tests::fixtures` for the state transition summary tests. +dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] } # In-process Signer for the manual testnet # verification example (`examples/dpns_marketplace_testnet.rs`). simple-signer = { path = "../simple-signer", features = ["state-transitions"] } 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 7b568a5bf78..a5a360951f5 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 @@ -27,7 +27,9 @@ use dashcore::secp256k1::PublicKey; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{IdentityPublicKey, KeyType}; use dpp::prelude::Identifier; -use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, KeyDerivationType}; +use key_wallet::bip32::{ + ApplicationKeyPurpose, ChildNumber, DerivationPath, ExtendedPrivKey, KeyDerivationType, +}; use key_wallet::dip9::{ IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, }; @@ -114,6 +116,78 @@ pub fn identity_auth_derivation_path_for_type( ])) } +/// A DashPay Connect key: the DIP-13 application sub-features +/// (dashpay/dips#191), whose paths key-wallet builds. Identity, request and +/// contract ids are DIP-14 256-bit children, so two devices restored from +/// one seed derive the same key without coordination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectKey { + /// `m/9'/coin'/5'/6'/0'/identity_id'/request_id'`; `request_id` is + /// `hash256` of the app's ephemeral public key. + SessionAuthentication { + identity_id: Identifier, + request_id: [u8; 32], + }, + /// `m/9'/coin'/5'/7'/0'/identity_id'/contract_id'/purpose'`: one half of + /// the encryption pair an identity holds for a contract. + AppEncryption { + identity_id: Identifier, + contract_id: [u8; 32], + purpose: ApplicationKeyPurpose, + }, +} + +impl ConnectKey { + /// The key's derivation path on `network`. + pub fn derivation_path(&self, network: key_wallet::Network) -> DerivationPath { + match *self { + ConnectKey::SessionAuthentication { + identity_id, + request_id, + } => DerivationPath::application_session_authentication_path( + network, + identity_id.to_buffer(), + request_id, + ), + ConnectKey::AppEncryption { + identity_id, + contract_id, + purpose, + } => DerivationPath::application_encryption_path( + network, + identity_id.to_buffer(), + contract_id, + purpose, + ), + } + } +} + +/// Derive the ECDSA secp256k1 keypair of `key` 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. +pub fn derive_connect_keypair_from_master( + master: &ExtendedPrivKey, + network: key_wallet::Network, + key: ConnectKey, +) -> Result { + use key_wallet::bip32::ExtendedPubKey; + + let path = key.derivation_path(network); + // See `derive_ecdsa_identity_auth_keypair_from_master` for why the + // intermediate `ExtendedPrivKey` needs no explicit wipe. + let derived = master.derive_priv(&path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to derive connect key: {e}")) + })?; + let extended_pub = ExtendedPubKey::from_priv(&derived); + + Ok(DerivedIdentityAuthKey { + derivation_path: path, + private_key: Zeroizing::new(derived.private_key.to_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 — @@ -638,4 +712,74 @@ mod tests { ); } } + + // ── DashPay Connect keys ────────────────────────────────────────── + + const CONNECT_IDENTITY: [u8; 32] = [0x35; 32]; + const CONNECT_LEAF: [u8; 32] = [0x6B; 32]; + + fn session_key() -> ConnectKey { + ConnectKey::SessionAuthentication { + identity_id: Identifier::from(CONNECT_IDENTITY), + request_id: CONNECT_LEAF, + } + } + + fn encryption_key(purpose: ApplicationKeyPurpose) -> ConnectKey { + ConnectKey::AppEncryption { + identity_id: Identifier::from(CONNECT_IDENTITY), + contract_id: CONNECT_LEAF, + purpose, + } + } + + /// The keys key-wallet pins for these inputs; a change orphans every key + /// already registered on-chain. The public key is the compressed point of + /// the returned scalar. + #[test] + fn connect_keypairs_match_the_pinned_vectors() { + use dashcore::secp256k1::{PublicKey as SecpPublicKey, SecretKey}; + + for (network, key, expected) in [ + ( + Network::Testnet, + session_key(), + "022c8b2e806244482374b1caf8306146dc03aad3b99a5954efd5e70a0eddd37a5d", + ), + ( + Network::Mainnet, + encryption_key(ApplicationKeyPurpose::Encryption), + "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434", + ), + ] { + let derived = derive_connect_keypair_from_master(&master_for(network), network, key) + .expect("connect derive"); + assert_eq!(hex::encode(derived.public_key), expected); + assert_eq!(derived.derivation_path, key.derivation_path(network)); + + let secret: [u8; 32] = *derived.private_key; + let sk = SecretKey::from_secret_bytes(secret).expect("valid scalar"); + assert_eq!( + SecpPublicKey::from_secret_key(&sk).serialize(), + derived.public_key + ); + } + } + + /// The two halves of an encryption pair and the session key are distinct. + #[test] + fn connect_keys_are_distinct_per_kind_and_purpose() { + let master = master_for(Network::Testnet); + let derive = |key| { + derive_connect_keypair_from_master(&master, Network::Testnet, key) + .expect("connect derive") + .public_key + }; + let session = derive(session_key()); + let encryption = derive(encryption_key(ApplicationKeyPurpose::Encryption)); + let decryption = derive(encryption_key(ApplicationKeyPurpose::Decryption)); + assert_ne!(session, encryption); + assert_ne!(session, decryption); + assert_ne!(encryption, decryption); + } } 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 66be6d90543..8e121a95005 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -44,6 +44,7 @@ mod key_selection; mod loading; mod register_from_addresses; mod registration; +mod state_transition_summary; mod top_up; mod top_up_from_addresses; mod transfer; @@ -101,9 +102,15 @@ pub use dpns_marketplace::{ DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS, }; pub use identity_handle::{ - 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, + 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, ConnectKey, DerivedIdentityAuthKey, IdentityWallet, + IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, +}; +pub use state_transition_summary::{ + check_document_type_name, decode_state_transition, summarize_state_transition, + BatchedTransitionSummary, BatchedTransitionTarget, DataContractSummary, StateTransitionSummary, + StateTransitionSummaryKind, }; // Helpers declared on `identity_handle.rs` that siblings reach diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs new file mode 100644 index 00000000000..d97da1c9594 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/state_transition_summary.rs @@ -0,0 +1,1545 @@ +//! What a state transition a dApp hands the wallet asks for, before the user +//! approves it (DashConnect `dash-st:` links / QRs, DashPay Connect `sign`). +//! +//! The wallet must never sign opaque bytes a web page hands it without showing +//! the user what they are. [`summarize_state_transition`] decodes any kind with +//! rs-dpp's exact untrusted decoder and returns a [`StateTransitionSummary`]: +//! the common fields every kind has, a typed summary for the kinds the approval +//! sheet describes, and a rendering of every material field the typed summary +//! does not cover. Nothing is refused on kind. +//! +//! This module does not sign and does not broadcast. + +use std::fmt; + +use dpp::data_contract::serialized_version::DataContractInSerializationFormat; +use dpp::identity::contract_bounds::ContractBounds; +use dpp::identity::KeyID; +use dpp::prelude::{Identifier, UserFeeIncrease}; +use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; +use dpp::state_transition::StateTransitionWitnessSigned; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +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_transition::DocumentTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionTypeGetter; +use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; +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, DocumentTransition, TokenTransition, +}; +use dpp::state_transition::batch_transition::document_base_transition::v1::v1_methods::DocumentBaseTransitionV1Methods; +use dpp::state_transition::batch_transition::document_base_transition::v2::v2_methods::DocumentBaseTransitionV2Methods; +use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +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_claim_transition::v0::v0_methods::TokenClaimTransitionV0Methods; +use dpp::state_transition::batch_transition::token_config_update_transition::v0::v0_methods::TokenConfigUpdateTransitionV0Methods; +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_emergency_action_transition::v0::v0_methods::TokenEmergencyActionTransitionV0Methods; +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_set_price_for_direct_purchase_transition::v0::v0_methods::TokenSetPriceForDirectPurchaseTransitionV0Methods; +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::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::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; +use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::state_transition::{ + StateTransition, StateTransitionOwned, StateTransitionType, STATE_TRANSITION_MAX_ENCODED_BYTES, +}; + +use crate::error::PlatformWalletError; + +/// Kinds some dApps send serialized on their own, without the +/// `StateTransition` variant tag in front (Yappr sends identity updates that +/// way, from wasm-dpp2's inner `toBytes()`). +pub const UNTAGGED_STATE_TRANSITION_KINDS: &[StateTransitionType] = &[ + StateTransitionType::IdentityUpdate, + StateTransitionType::Batch, +]; + +/// What a state transition asks for. +#[derive(Debug, Clone, PartialEq)] +pub struct StateTransitionSummary { + /// `StateTransition::name()`, e.g. `IdentityUpdate`, + /// `DocumentsBatch([Create, TokenTransfer])`, `MasternodeVote`. + pub kind_name: String, + /// The identity the transition acts for; `None` for the asset-lock-funded + /// and shielded kinds. + pub owner_id: Option, + /// Whether the transition already carries a non-empty signature: its own + /// signature, or for the kinds spending platform addresses any input + /// witness. Kinds authorized only by a shielded proof report `false`. A + /// `sign` request must arrive unsigned. + pub is_signed: bool, + /// Percentage added to the processing fee Platform charges (`65535` is + /// about 656 times the base). Part of the signed bytes, so an approval + /// sheet must show a non-zero value. + pub user_fee_increase: UserFeeIncrease, + /// The bytes that decoded, always tagged. These, not the input, are what + /// a caller signs after approval. + pub serialized: Vec, + pub kind: StateTransitionSummaryKind, +} + +impl StateTransitionSummary { + /// Whether the typed summary shows every material field. When it does not, + /// a wallet must render the `details` it carries before approval. + pub fn is_complete(&self) -> bool { + match &self.kind { + StateTransitionSummaryKind::Batch { transitions, .. } => transitions + .iter() + .all(BatchedTransitionSummary::is_complete), + StateTransitionSummaryKind::IdentityUpdate { .. } + | StateTransitionSummaryKind::CreditTransfer { .. } => true, + StateTransitionSummaryKind::DataContractCreate(_) + | StateTransitionSummaryKind::DataContractUpdate(_) + | StateTransitionSummaryKind::Other { .. } => false, + } + } +} + +/// The typed part of a [`StateTransitionSummary`]. +#[derive(Debug, Clone, PartialEq)] +pub enum StateTransitionSummaryKind { + IdentityUpdate { + identity_id: Identifier, + add_public_keys: Vec, + disable_public_key_ids: Vec, + }, + Batch { + owner_id: Identifier, + transitions: Vec, + }, + CreditTransfer { + identity_id: Identifier, + recipient_id: Identifier, + amount: u64, + }, + DataContractCreate(DataContractSummary), + DataContractUpdate(DataContractSummary), + /// A kind without a describer. `details` is the whole decoded transition. + Other { + details: String, + }, +} + +/// The contract a data contract create or update registers. +#[derive(Debug, Clone, PartialEq)] +pub struct DataContractSummary { + pub contract_id: Identifier, + pub owner_id: Identifier, + /// Document type names, in the contract's order. + pub document_type_names: Vec, + /// The whole transition: the contract (schemas, tokens and their + /// distribution rules, groups, keywords), and for a create the contract + /// group it registers and the memberships it declares. Everything the + /// names above do not show. + pub details: String, +} + +/// One transition inside a batch. +#[derive(Debug, Clone, PartialEq)] +pub struct BatchedTransitionSummary { + pub data_contract_id: Identifier, + /// The rs-dpp action name (`Create`, `Transfer`, `DirectPurchase`, ...). + pub action: String, + pub target: BatchedTransitionTarget, + /// Credits or tokens moved: a document purchase price or update-price + /// value, a token transfer / mint / burn amount, or a direct purchase's + /// total agreed price. + pub amount: Option, + /// The identity on the other side, read against `action`: 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. + pub recipient_id: Option, + /// Tokens bought by a direct purchase. + pub token_count: Option, + /// The material fields `amount` / `recipient_id` / `token_count` do not + /// cover, one per line (document data, a config change item, a price + /// schedule, notes, group action info). `None` when there are none. + pub details: Option, +} + +impl BatchedTransitionSummary { + /// Whether `amount` / `recipient_id` / `token_count` show every material + /// field; when not, `details` must be rendered before approval. + pub fn is_complete(&self) -> bool { + self.details.is_none() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BatchedTransitionTarget { + Document { + document_type: String, + document_id: Identifier, + }, + Token { + token_id: Identifier, + token_contract_position: u16, + }, +} + +/// Decodes `bytes` and summarizes what they ask for. +/// +/// Tagged `StateTransition` bytes and the untagged kinds in +/// [`UNTAGGED_STATE_TRANSITION_KINDS`] are accepted. Every framing is tried +/// with the exact decoder, which refuses leftover bytes, and exactly one must +/// decode: a payload that decodes under two is refused rather than described +/// under whichever was tried first, since the caller signs what the user was +/// shown. +pub fn summarize_state_transition( + bytes: &[u8], +) -> Result { + let (transition, serialized) = decode_state_transition(bytes, UNTAGGED_STATE_TRANSITION_KINDS)?; + summarize(&transition, serialized) +} + +/// Decodes `bytes` as a tagged `StateTransition` or as one of `untagged_kinds` +/// serialized on its own. Returns the transition and its tagged bytes. +pub fn decode_state_transition( + bytes: &[u8], + untagged_kinds: &[StateTransitionType], +) -> Result<(StateTransition, Vec), PlatformWalletError> { + let mut decoded: Vec<(StateTransition, String)> = Vec::new(); + let mut failures: Vec = Vec::new(); + + match StateTransition::deserialize_from_bytes_untrusted_exact(bytes) { + Ok(transition) => decoded.push((transition, "tagged".to_string())), + Err(error) => failures.push(format!("tagged: {error}")), + } + for kind in untagged_kinds { + match StateTransition::deserialize_untagged_untrusted_exact(*kind, bytes) { + Ok(transition) => decoded.push((transition, format!("untagged {kind}"))), + Err(error) => failures.push(format!("untagged {kind}: {error}")), + } + } + + match decoded.len() { + 0 => Err(PlatformWalletError::InvalidParameter(format!( + "Failed to deserialize state transition in any supported framing ({})", + failures.join("; ") + ))), + 1 => { + let (transition, _) = decoded.pop().expect("one decoded framing"); + let serialized = transition.serialize_to_bytes().map_err(|error| { + PlatformWalletError::InvalidParameter(format!( + "Decoded state transition does not re-serialize: {error}" + )) + })?; + Ok((transition, serialized)) + } + _ => Err(PlatformWalletError::InvalidParameter(format!( + "Ambiguous state transition framing: the bytes decode as {}; refusing to guess \ + which one the sender meant", + decoded + .iter() + .map(|(transition, framing)| format!("{} ({framing})", transition.name())) + .collect::>() + .join(", ") + ))), + } +} + +/// Document type names reach the approval sheet as their own field, unescaped. Consensus only +/// accepts `^[a-zA-Z0-9-_]{1,64}$`, so anything else is refused here rather than shown. Every +/// parser that projects a document type name for approval must apply it. +pub fn check_document_type_name(name: &str) -> Result<(), PlatformWalletError> { + let valid = (1..=64).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + if valid { + Ok(()) + } else { + Err(PlatformWalletError::InvalidParameter(format!( + "Invalid document type name {name:?}" + ))) + } +} + +fn summarize( + transition: &StateTransition, + serialized: Vec, +) -> Result { + let mut budget = DetailsBudget::default(); + let kind = match transition { + StateTransition::IdentityUpdate(update) => { + // A key bound to one document type carries that type's name to the approval + // sheet in its own field, like a batch row's document type. + for key in update.public_keys_to_add() { + if let Some(ContractBounds::SingleContractDocumentType { + document_type_name, .. + }) = key.contract_bounds() + { + check_document_type_name(document_type_name)?; + } + } + StateTransitionSummaryKind::IdentityUpdate { + identity_id: update.identity_id(), + add_public_keys: update.public_keys_to_add().to_vec(), + disable_public_key_ids: update.public_key_ids_to_disable().to_vec(), + } + } + StateTransition::Batch(batch) => StateTransitionSummaryKind::Batch { + owner_id: batch.owner_id(), + transitions: summarize_batch(batch, &mut budget)?, + }, + StateTransition::IdentityCreditTransfer(transfer) => { + StateTransitionSummaryKind::CreditTransfer { + identity_id: transfer.identity_id(), + recipient_id: transfer.recipient_id(), + amount: transfer.amount(), + } + } + StateTransition::DataContractCreate(create) => { + StateTransitionSummaryKind::DataContractCreate(summarize_contract( + create.data_contract(), + budget.render(create)?, + )?) + } + StateTransition::DataContractUpdate(update) => { + StateTransitionSummaryKind::DataContractUpdate(summarize_contract( + update.data_contract(), + budget.render(update)?, + )?) + } + other => StateTransitionSummaryKind::Other { + details: budget.render(other)?, + }, + }; + + Ok(StateTransitionSummary { + kind_name: transition.name(), + owner_id: transition.owner_id(), + is_signed: carries_signature(transition), + user_fee_increase: transition.user_fee_increase(), + serialized, + kind, + }) +} + +/// Whether `transition` carries a non-empty signature or, for the kinds spending platform +/// addresses, any input witness; [`StateTransition::signature`] alone is `None` for those. +/// +/// Every kind is listed, so a kind DPP adds later fails to compile here until someone decides +/// how it is authenticated, rather than reporting it unsigned. +fn carries_signature(transition: &StateTransition) -> bool { + let has_witness = match transition { + StateTransition::IdentityCreateFromAddresses(st) => !st.witnesses().is_empty(), + StateTransition::IdentityTopUpFromAddresses(st) => !st.witnesses().is_empty(), + StateTransition::AddressFundsTransfer(st) => !st.witnesses().is_empty(), + StateTransition::AddressFundingFromAssetLock(st) => !st.witnesses().is_empty(), + StateTransition::AddressCreditWithdrawal(st) => !st.witnesses().is_empty(), + StateTransition::Shield(st) => !st.witnesses().is_empty(), + // Authorized by the top-level signature, or (shielded kinds) by a shielded proof that + // `signature()` does not report. + StateTransition::DataContractCreate(_) + | StateTransition::DataContractUpdate(_) + | StateTransition::Batch(_) + | StateTransition::IdentityCreate(_) + | StateTransition::IdentityTopUp(_) + | StateTransition::IdentityCreditWithdrawal(_) + | StateTransition::IdentityUpdate(_) + | StateTransition::IdentityCreditTransfer(_) + | StateTransition::MasternodeVote(_) + | StateTransition::IdentityCreditTransferToAddresses(_) + | StateTransition::ShieldedTransfer(_) + | StateTransition::Unshield(_) + | StateTransition::ShieldFromAssetLock(_) + | StateTransition::ShieldedWithdrawal(_) + | StateTransition::IdentityCreateFromShieldedPool(_) + | StateTransition::ShieldFromIdentity(_) + | StateTransition::IdentityTopUpFromShieldedPool(_) + | StateTransition::IdentityKeyLimitsUpdate(_) + | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) + | StateTransition::TokenShieldedTransferWithShieldedFee(_) + | StateTransition::TokenUnshieldWithShieldedFee(_) + | StateTransition::TokenPurchaseFromShieldedPool(_) => false, + }; + has_witness || transition.signature().is_some_and(|sig| !sig.is_empty()) +} + +/// Total bytes of `details` one summary may render: 64 times the largest transition the decoder +/// accepts. Compact `Debug` of the densest values measured (a minimal token configuration) is +/// about 42 times its encoding, so a valid request fits; nested values cannot grow past the bound. +pub const MAX_DETAILS_BYTES: usize = 64 * STATE_TRANSITION_MAX_ENCODED_BYTES; + +/// Renders `details` with compact `Debug`, which quotes and escapes every string a dApp controls, +/// under one byte budget for the whole summary: values, labels, fixed text and the newlines +/// joining lines are all charged. Formatting stops as soon as the budget is spent, and the +/// summary is refused rather than shown truncated. +struct DetailsBudget { + limit: usize, + used: usize, +} + +impl Default for DetailsBudget { + fn default() -> Self { + Self { + limit: MAX_DETAILS_BYTES, + used: 0, + } + } +} + +impl DetailsBudget { + /// Formats `args` against the remaining budget. + fn write(&mut self, args: fmt::Arguments<'_>) -> Result { + struct Bounded<'a> { + out: String, + remaining: &'a mut usize, + } + impl fmt::Write for Bounded<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + *self.remaining = self.remaining.checked_sub(s.len()).ok_or(fmt::Error)?; + self.out.push_str(s); + Ok(()) + } + } + + let mut remaining = self.limit - self.used; + let mut sink = Bounded { + out: String::new(), + remaining: &mut remaining, + }; + fmt::write(&mut sink, args).map_err(|_| self.exceeded())?; + let out = sink.out; + self.used = self.limit - remaining; + Ok(out) + } + + fn exceeded(&self) -> PlatformWalletError { + PlatformWalletError::InvalidParameter(format!( + "State transition details exceed {} bytes; refusing to summarize it", + self.limit + )) + } + + /// ``. + fn render(&mut self, value: &dyn fmt::Debug) -> Result { + self.write(format_args!("{value:?}")) + } + + /// `label: `. + fn line(&mut self, label: &str, value: &dyn fmt::Debug) -> Result { + self.write(format_args!("{label}: {value:?}")) + } + + /// `lines` joined by newlines, which are charged too; `None` when there are none. + fn join(&mut self, lines: Vec) -> Result, PlatformWalletError> { + let separators = lines.len().saturating_sub(1); + self.used = self + .used + .checked_add(separators) + .filter(|used| *used <= self.limit) + .ok_or_else(|| self.exceeded())?; + Ok((!lines.is_empty()).then(|| lines.join("\n"))) + } +} + +/// `details` is the whole transition, not only its contract: a create transition also carries +/// the contract group it registers and the memberships it declares. +fn summarize_contract( + contract: &DataContractInSerializationFormat, + details: String, +) -> Result { + let document_type_names: Vec = contract.document_schemas().keys().cloned().collect(); + for name in &document_type_names { + check_document_type_name(name)?; + } + Ok(DataContractSummary { + contract_id: contract.id(), + owner_id: contract.owner_id(), + document_type_names, + details, + }) +} + +fn summarize_batch( + batch: &BatchTransition, + budget: &mut DetailsBudget, +) -> Result, PlatformWalletError> { + batch + .transitions_iter() + .map(|transition| match transition { + BatchedTransitionRef::Document(document) => { + summarize_document_transition(document, budget) + } + BatchedTransitionRef::Token(token) => summarize_token_transition(token, budget), + }) + .collect() +} + +fn summarize_document_transition( + transition: &DocumentTransition, + budget: &mut DetailsBudget, +) -> Result { + check_document_type_name(transition.document_type_name())?; + let (amount, recipient_id) = 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 mut lines = Vec::new(); + if let Some(data) = transition.data() { + lines.push(budget.line("data", data)?); + } + if let DocumentTransition::Create(create) = transition { + if let Some((index_name, credits)) = create.prefunded_voting_balance() { + lines.push(budget.write(format_args!( + "prefunded voting balance: {credits} credits for index {index_name:?}" + ))?); + } + } + let base = transition.base(); + if let Some(payment) = base.token_payment_info_ref() { + lines.push(budget.line("token payment", payment)?); + } + if let Some(fees) = base.action_fee_agreement() { + lines.push(budget.line("action fees", &fees)?); + } + + Ok(BatchedTransitionSummary { + data_contract_id: transition.data_contract_id(), + action: format!("{:?}", transition.action_type()), + target: BatchedTransitionTarget::Document { + document_type: transition.document_type_name().clone(), + document_id: transition.get_id(), + }, + amount, + recipient_id, + token_count: None, + details: budget.join(lines)?, + }) +} + +fn summarize_token_transition( + transition: &TokenTransition, + budget: &mut DetailsBudget, +) -> Result { + let mut lines = Vec::new(); + let (amount, recipient_id, token_count, public_note) = match transition { + TokenTransition::Transfer(t) => { + if t.shared_encrypted_note().is_some() { + lines.push(budget.write(format_args!("shared encrypted note: present"))?); + } + if t.private_encrypted_note().is_some() { + lines.push(budget.write(format_args!("private encrypted note: present"))?); + } + ( + Some(t.amount()), + Some(t.recipient_id()), + None, + t.public_note(), + ) + } + TokenTransition::Mint(t) => { + if t.issued_to_identity_id().is_none() { + lines.push( + budget.write(format_args!("issued to: the token's default destination"))?, + ); + } + ( + Some(t.amount()), + t.issued_to_identity_id(), + None, + t.public_note(), + ) + } + TokenTransition::Burn(t) => (Some(t.burn_amount()), None, None, t.public_note()), + TokenTransition::Freeze(t) => (None, Some(t.frozen_identity_id()), None, t.public_note()), + TokenTransition::Unfreeze(t) => (None, Some(t.frozen_identity_id()), None, t.public_note()), + TokenTransition::DestroyFrozenFunds(t) => { + (None, Some(t.frozen_identity_id()), None, t.public_note()) + } + TokenTransition::DirectPurchase(t) => ( + Some(t.total_agreed_price()), + None, + Some(t.token_count()), + None, + ), + TokenTransition::Claim(t) => { + lines.push(budget.line("distribution type", &t.distribution_type())?); + (None, None, None, t.public_note()) + } + TokenTransition::EmergencyAction(t) => { + lines.push(budget.line("emergency action", &t.emergency_action())?); + (None, None, None, t.public_note()) + } + TokenTransition::ConfigUpdate(t) => { + lines.push(budget.line("config update", t.update_token_configuration_item())?); + (None, None, None, t.public_note()) + } + TokenTransition::SetPriceForDirectPurchase(t) => { + match t.price() { + Some(schedule) => lines.push(budget.line("price schedule", schedule)?), + None => lines + .push(budget.write(format_args!("price schedule: removed (not for sale)"))?), + } + (None, None, None, t.public_note()) + } + // Shielded-pool operations carry notes, proofs and amounts with no typed projection: the + // whole transition is rendered and the row is incomplete. + TokenTransition::Shield(_) + | TokenTransition::Unshield(_) + | TokenTransition::ShieldedTransfer(_) + | TokenTransition::MintToPool(_) + | TokenTransition::BurnFromPool(_) + | TokenTransition::ClaimToPool(_) + | TokenTransition::DirectPurchaseToPool(_) => { + lines.push(budget.line("transition", transition)?); + (None, None, None, None) + } + }; + if let Some(note) = public_note { + lines.push(budget.line("public note", note)?); + } + let base = transition.base(); + if let Some(group) = base.using_group_info() { + lines.push(budget.write(format_args!( + "group action: position {}, action id {}, {}", + group.group_contract_position, + group.action_id, + if group.action_is_proposer { + "proposing" + } else { + "signing an existing proposal" + } + ))?); + } + + Ok(BatchedTransitionSummary { + data_contract_id: transition.data_contract_id(), + action: transition.action_type().to_string(), + target: BatchedTransitionTarget::Token { + token_id: transition.token_id(), + token_contract_position: base.token_contract_position(), + }, + amount, + recipient_id, + token_count, + details: budget.join(lines)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::data_contract::document_type::action_fees::agreement::v0::DocumentActionFeeAgreementV0; + use dpp::state_transition::batch_transition::document_base_transition::v1::DocumentBaseTransitionV1; + use dpp::state_transition::batch_transition::document_base_transition::v2::DocumentBaseTransitionV2; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use dpp::tokens::token_payment_info::v0::TokenPaymentInfoV0; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; + use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; + use dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType; + use dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; + use dpp::identity::core_script::CoreScript; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::{platform_value, BinaryData}; + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::{ + BatchedTransition, DocumentTransferTransition, + }; + 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::token_base_transition::v0::TokenBaseTransitionV0; + use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; + use dpp::state_transition::batch_transition::token_claim_transition::v0::TokenClaimTransitionV0; + use dpp::state_transition::batch_transition::token_config_update_transition::v0::TokenConfigUpdateTransitionV0; + use dpp::state_transition::batch_transition::token_direct_purchase_transition::v0::TokenDirectPurchaseTransitionV0; + use dpp::state_transition::batch_transition::token_emergency_action_transition::v0::TokenEmergencyActionTransitionV0; + use dpp::state_transition::batch_transition::token_set_price_for_direct_purchase_transition::v0::TokenSetPriceForDirectPurchaseTransitionV0; + use dpp::state_transition::batch_transition::token_transfer_transition::v0::TokenTransferTransitionV0; + use dpp::state_transition::batch_transition::{ + BatchTransitionV1, DocumentCreateTransition, TokenClaimTransition, + TokenConfigUpdateTransition, TokenDirectPurchaseTransition, + TokenEmergencyActionTransition, TokenSetPriceForDirectPurchaseTransition, + TokenTransferTransition, + }; + use dpp::state_transition::identity_credit_transfer_transition::v0::IdentityCreditTransferTransitionV0; + use dpp::state_transition::identity_credit_withdrawal_transition::v1::IdentityCreditWithdrawalTransitionV1; + use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use dpp::data_contract::associated_token::token_configuration_convention::v0::TokenConfigurationConventionV0; + use dpp::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; + use dpp::data_contract::associated_token::token_configuration_localization::v0::TokenConfigurationLocalizationV0; + use dpp::data_contract::associated_token::token_configuration_localization::TokenConfigurationLocalization; + use dpp::platform_value::Value; + use dpp::contract_group::{ContractGroupMember, ContractGroupMembership, ContractGroupRegistration}; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV1; + use std::collections::BTreeSet; + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV1Getters; + use dpp::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; + use dpp::tokens::emergency_action::TokenEmergencyAction; + use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; + use dpp::withdrawal::Pooling; + use std::collections::BTreeMap; + + 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 token_base() -> TokenBaseTransition { + TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 4, + token_contract_position: 3, + data_contract_id: Identifier::from(CONTRACT), + token_id: Identifier::from(TOKEN), + using_group_info: 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 batch(transitions: Vec) -> StateTransition { + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from(OWNER), + transitions, + user_fee_increase: 1, + signature_public_key_id: 2, + signature: BinaryData::new(vec![0x88; 65]), + })) + } + + fn credit_transfer(user_fee_increase: u16) -> StateTransition { + IdentityCreditTransferTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from(RECIPIENT), + amount: 1_000, + nonce: 1, + user_fee_increase, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into() + } + + fn summarize_bytes(transition: &StateTransition) -> StateTransitionSummary { + summarize_state_transition(&transition.serialize_to_bytes().unwrap()).expect("summarizes") + } + + #[test] + fn a_mixed_batch_summarizes_one_row_per_transition() { + let transition = batch(vec![ + BatchedTransition::Document(DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base: document_base("post"), + entropy: [0xEE; 32], + data: BTreeMap::from([("message".to_string(), platform_value!("hi"))]), + prefunded_voting_balance: None, + }, + ))), + BatchedTransition::Document(DocumentTransition::Transfer( + DocumentTransferTransition::V0(DocumentTransferTransitionV0 { + base: document_base("profile"), + revision: 2, + recipient_owner_id: Identifier::from(RECIPIENT), + }), + )), + 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, + }, + ))), + BatchedTransition::Token(TokenTransition::DirectPurchase( + TokenDirectPurchaseTransition::V0(TokenDirectPurchaseTransitionV0 { + base: token_base(), + token_count: 100, + total_agreed_price: 100_000_000, + }), + )), + ]); + let summary = summarize_bytes(&transition); + + assert_eq!( + summary.kind_name, + "DocumentsBatch([Create, Transfer, TokenTransfer, TokenDirectPurchase])" + ); + assert_eq!(summary.owner_id, Some(Identifier::from(OWNER))); + assert!(summary.is_signed); + assert_eq!(summary.user_fee_increase, 1); + assert_eq!(summary.serialized, transition.serialize_to_bytes().unwrap()); + // The document data has no typed projection. + assert!(!summary.is_complete()); + + let StateTransitionSummaryKind::Batch { + owner_id, + transitions, + } = summary.kind + else { + panic!("expected a batch"); + }; + assert_eq!(owner_id, Identifier::from(OWNER)); + let [create, transfer, token_transfer, purchase] = transitions.as_slice() else { + panic!("expected four rows"); + }; + + assert_eq!(create.action, "Create"); + assert_eq!( + create.target, + BatchedTransitionTarget::Document { + document_type: "post".to_string(), + document_id: Identifier::from([0x0D; 32]), + } + ); + let create_details = create.details.as_deref().expect("data is rendered"); + assert!(create_details.contains("\"message\""), "{create_details}"); + + assert_eq!(transfer.action, "Transfer"); + assert_eq!(transfer.recipient_id, Some(Identifier::from(RECIPIENT))); + assert_eq!(transfer.details, None); + + assert_eq!(token_transfer.action, "Transfer"); + assert_eq!( + token_transfer.target, + BatchedTransitionTarget::Token { + token_id: Identifier::from(TOKEN), + token_contract_position: 3, + } + ); + assert_eq!(token_transfer.amount, Some(250)); + assert_eq!( + token_transfer.recipient_id, + Some(Identifier::from(RECIPIENT)) + ); + + assert_eq!(purchase.action, "DirectPurchase"); + assert_eq!(purchase.amount, Some(100_000_000)); + assert_eq!(purchase.token_count, Some(100)); + assert_eq!(purchase.details, None); + } + + #[test] + fn an_identity_update_carries_every_added_key_with_its_limits() { + let transition: StateTransition = IdentityUpdateTransitionV0 { + signature: BinaryData::new(vec![0x99; 65]), + signature_public_key_id: 3, + 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![0xbb; 65]), + contract_bounds: None, + 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, + } + .into(); + let summary = summarize_bytes(&transition); + + assert_eq!(summary.kind_name, "IdentityUpdate"); + assert!(summary.is_complete()); + let StateTransitionSummaryKind::IdentityUpdate { + identity_id, + add_public_keys, + disable_public_key_ids, + } = summary.kind + else { + panic!("expected an identity update"); + }; + assert_eq!(identity_id, Identifier::from([0x11; 32])); + assert_eq!(disable_public_key_ids, vec![4, 8]); + assert_eq!(add_public_keys[0].total_budget(), Some(10_000_000_000)); + assert_eq!(add_public_keys[0].expires_at(), Some(1_800_000_000_000)); + } + + /// Identity updates arrive untagged from some dApps; the summary carries + /// the tagged bytes, which are what the wallet signs. + #[test] + fn an_untagged_identity_update_is_summarized_with_tagged_bytes() { + let transition: StateTransition = IdentityUpdateTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + revision: 1, + nonce: 1, + disable_public_keys: vec![3], + ..Default::default() + } + .into(); + let tagged = transition.serialize_to_bytes().unwrap(); + let StateTransition::IdentityUpdate(inner) = &transition else { + unreachable!() + }; + let untagged = inner.serialize_to_bytes().unwrap(); + assert_ne!(untagged, tagged); + + let summary = summarize_state_transition(&untagged).expect("summarizes"); + assert_eq!(summary.kind_name, "IdentityUpdate"); + assert_eq!(summary.serialized, tagged); + } + + #[test] + fn a_credit_transfer_summary() { + let summary = summarize_bytes(&credit_transfer(0)); + assert_eq!( + summary.kind, + StateTransitionSummaryKind::CreditTransfer { + identity_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from(RECIPIENT), + amount: 1_000, + } + ); + assert!(!summary.is_signed); + } + + /// The fee multiplier is part of the signed bytes and scales the + /// processing fee, so two otherwise identical transfers must not summarize + /// to the same thing. + #[test] + fn the_user_fee_increase_is_part_of_the_summary() { + let plain = summarize_bytes(&credit_transfer(0)); + let maxed = summarize_bytes(&credit_transfer(u16::MAX)); + assert_eq!(plain.kind, maxed.kind); + assert_eq!(plain.user_fee_increase, 0); + assert_eq!(maxed.user_fee_increase, u16::MAX); + } + + /// An address funds transfer has no signature of its own; it is signed by + /// its input witnesses, so it reports signed once it carries one. + #[test] + fn a_witness_signed_transfer_reports_whether_it_is_signed() { + use dpp::address_funds::{AddressWitness, PlatformAddress}; + use dpp::platform_value::BinaryData; + use dpp::state_transition::address_funds_transfer_transition::v0::AddressFundsTransferTransitionV0; + use dpp::state_transition::address_funds_transfer_transition::AddressFundsTransferTransition; + + let transfer = |input_witnesses: Vec| -> StateTransition { + AddressFundsTransferTransition::V0(AddressFundsTransferTransitionV0 { + inputs: BTreeMap::from([(PlatformAddress::P2pkh([0xab; 20]), (1, 5_000))]), + outputs: BTreeMap::from([(PlatformAddress::P2pkh([0xcd; 20]), 4_000)]), + fee_strategy: vec![], + user_fee_increase: 0, + input_witnesses, + }) + .into() + }; + + assert!(!summarize_bytes(&transfer(vec![])).is_signed); + assert!( + summarize_bytes(&transfer(vec![AddressWitness::P2pkh { + signature: BinaryData::new(vec![0x1f; 65]), + }])) + .is_signed + ); + } + + /// A shielded-pool token operation has no typed projection: it renders + /// whole, so its amount reaches the details, and the row is incomplete. + #[test] + fn a_shielded_pool_token_row_renders_whole_and_is_incomplete() { + use dpp::state_transition::batch_transition::batched_transition::token_mint_to_pool_transition::TokenMintToPoolTransitionV0; + use dpp::state_transition::batch_transition::TokenMintToPoolTransition; + + let summary = summarize_bytes(&batch(vec![BatchedTransition::Token( + TokenTransition::MintToPool(TokenMintToPoolTransition::V0( + TokenMintToPoolTransitionV0 { + base: token_base(), + amount: 7_654_321, + ..Default::default() + }, + )), + )])); + let StateTransitionSummaryKind::Batch { transitions, .. } = &summary.kind else { + panic!("a batch: {:?}", summary.kind) + }; + assert_eq!(transitions.len(), 1); + assert!(!transitions[0].is_complete()); + assert_eq!(transitions[0].amount, None); + let details = transitions[0].details.as_deref().expect("details"); + assert!(details.contains("amount: 7654321"), "{details}"); + assert!(!summary.is_complete()); + } + + /// Two config updates granting manual minting to different takers must not + /// summarize the same; neither is complete from the typed fields alone. + #[test] + fn token_details_cover_config_update_emergency_price_and_claim() { + let config_update = |takers: AuthorizedActionTakers| { + BatchedTransition::Token(TokenTransition::ConfigUpdate( + TokenConfigUpdateTransition::V0(TokenConfigUpdateTransitionV0 { + base: token_base(), + update_token_configuration_item: TokenConfigurationChangeItem::ManualMinting( + takers, + ), + public_note: Some("grant".to_string()), + }), + )) + }; + let summary = summarize_bytes(&batch(vec![ + config_update(AuthorizedActionTakers::ContractOwner), + config_update(AuthorizedActionTakers::Identity(Identifier::from( + RECIPIENT, + ))), + BatchedTransition::Token(TokenTransition::EmergencyAction( + TokenEmergencyActionTransition::V0(TokenEmergencyActionTransitionV0 { + base: token_base(), + emergency_action: TokenEmergencyAction::Pause, + public_note: None, + }), + )), + BatchedTransition::Token(TokenTransition::SetPriceForDirectPurchase( + TokenSetPriceForDirectPurchaseTransition::V0( + TokenSetPriceForDirectPurchaseTransitionV0 { + base: token_base(), + price: Some(TokenPricingSchedule::SinglePrice(4_200)), + public_note: None, + }, + ), + )), + BatchedTransition::Token(TokenTransition::Claim(TokenClaimTransition::V0( + TokenClaimTransitionV0 { + base: token_base(), + distribution_type: TokenDistributionType::Perpetual, + public_note: None, + }, + ))), + ])); + assert!(!summary.is_complete()); + let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { + panic!("expected a batch"); + }; + let details: Vec = transitions + .into_iter() + .map(|row| row.details.expect("every row renders details")) + .collect(); + + assert_ne!(details[0], details[1]); + assert!(details[0].contains("ManualMinting"), "{}", details[0]); + assert!(details[0].contains("ContractOwner"), "{}", details[0]); + assert!( + details[0].contains("public note: \"grant\""), + "{}", + details[0] + ); + assert!( + details[1].contains(&format!("{:?}", Identifier::from(RECIPIENT))), + "{}", + details[1] + ); + assert!( + details[2].contains("emergency action: Pause"), + "{}", + details[2] + ); + assert!(details[3].contains("SinglePrice(4200)"), "{}", details[3]); + assert!( + details[4].contains("distribution type: Perpetual"), + "{}", + details[4] + ); + } + + #[test] + fn a_kind_without_a_describer_carries_the_whole_transition() { + let transition: StateTransition = IdentityCreditWithdrawalTransitionV1 { + identity_id: Identifier::from([0x11; 32]), + amount: 123_456_789, + core_fee_per_byte: 7, + pooling: Pooling::Never, + output_script: Some(CoreScript::from_bytes(vec![0x76, 0xa9, 0x14, 0xAB])), + nonce: 3, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(); + let summary = summarize_bytes(&transition); + + assert_eq!(summary.kind_name, "IdentityCreditWithdrawal"); + assert!(!summary.is_complete()); + let StateTransitionSummaryKind::Other { details } = summary.kind else { + panic!("expected other"); + }; + assert!(details.contains("amount: 123456789"), "{details}"); + assert!(details.contains("Never"), "{details}"); + assert!(details.contains("output_script"), "{details}"); + } + + #[test] + fn trailing_bytes_and_garbage_are_refused() { + let mut padded = credit_transfer(0).serialize_to_bytes().unwrap(); + padded.push(0); + assert!(matches!( + summarize_state_transition(&padded), + Err(PlatformWalletError::InvalidParameter(message)) if message.contains("left over") + )); + assert!(summarize_state_transition(&[0xde, 0xad, 0xbe, 0xef]).is_err()); + } + + /// Tagged bytes of every described kind, and the untagged kinds' own + /// bytes, decode under exactly one framing. + #[test] + fn every_described_kind_decodes_under_one_framing() { + for transition in [ + credit_transfer(0), + batch(vec![]), + IdentityUpdateTransitionV0::default().into(), + ] { + let tagged = transition.serialize_to_bytes().unwrap(); + let (decoded, serialized) = + decode_state_transition(&tagged, UNTAGGED_STATE_TRANSITION_KINDS) + .expect("one framing"); + assert_eq!(decoded, transition); + assert_eq!(serialized, tagged); + } + } + + /// Asking for the same untagged kind twice makes one payload decode under + /// two framings, which is refused. + #[test] + fn ambiguous_framing_is_refused() { + let transition: StateTransition = IdentityUpdateTransitionV0::default().into(); + let StateTransition::IdentityUpdate(inner) = &transition else { + unreachable!() + }; + let untagged = inner.serialize_to_bytes().unwrap(); + let result = decode_state_transition( + &untagged, + &[ + StateTransitionType::IdentityUpdate, + StateTransitionType::IdentityUpdate, + ], + ); + assert!(matches!( + result, + Err(PlatformWalletError::InvalidParameter(message)) if message.contains("Ambiguous") + )); + } + + /// A transfer or delete names no amount of its own, but its base can + /// commit the owner to a token cost (V1) or to action fees (V2); the row + /// must render them and not claim to be complete. + #[test] + fn document_rows_render_token_payment_and_action_fees() { + let payment = TokenPaymentInfoV0 { + payment_token_contract_id: None, + token_contract_position: 0, + minimum_token_cost: None, + maximum_token_cost: Some(5_000), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + } + .into(); + let transfer = |base: DocumentBaseTransition| { + BatchedTransition::Document(DocumentTransition::Transfer( + DocumentTransferTransition::V0(DocumentTransferTransitionV0 { + base, + revision: 2, + recipient_owner_id: Identifier::from(RECIPIENT), + }), + )) + }; + let summary = summarize_bytes(&batch(vec![ + transfer(DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([0x0D; 32]), + identity_contract_nonce: 1, + document_type_name: "card".to_string(), + data_contract_id: Identifier::from(CONTRACT), + token_payment_info: Some(payment), + })), + transfer(DocumentBaseTransition::V2(DocumentBaseTransitionV2 { + id: Identifier::from([0x0D; 32]), + identity_contract_nonce: 2, + document_type_name: "card".to_string(), + data_contract_id: Identifier::from(CONTRACT), + token_payment_info: None, + action_fee_agreement: Some( + DocumentActionFeeAgreementV0 { + owner: 700, + moderators: 300, + fee_multiplier: None, + } + .into(), + ), + })), + ])); + assert!(!summary.is_complete()); + let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { + panic!("expected a batch"); + }; + let token = transitions[0] + .details + .as_deref() + .expect("token payment rendered"); + assert!(token.contains("token payment:"), "{token}"); + assert!(token.contains("5000"), "{token}"); + let fees = transitions[1] + .details + .as_deref() + .expect("action fees rendered"); + assert!(fees.contains("action fees:"), "{fees}"); + assert!(fees.contains("700") && fees.contains("300"), "{fees}"); + } + + /// Tokens, groups and schemas are what a contract registration asks for + /// and pays on; the names alone do not show them. + #[test] + fn a_data_contract_create_is_not_complete_from_its_names() { + let created = get_data_contract_fixture( + Some(Identifier::from(OWNER)), + 1, + PlatformVersion::latest().protocol_version, + ); + let contract = DataContractInSerializationFormat::try_from_platform_versioned( + created.data_contract(), + PlatformVersion::latest(), + ) + .expect("serialization format"); + let transition: StateTransition = DataContractCreateTransitionV0 { + data_contract: contract, + identity_nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into(); + let summary = summarize_bytes(&transition); + assert!(!summary.is_complete()); + let StateTransitionSummaryKind::DataContractCreate(contract) = summary.kind else { + panic!("expected a contract create"); + }; + assert_eq!(contract.owner_id, Identifier::from(OWNER)); + assert!( + contract.details.contains("niceDocument"), + "{}", + contract.details + ); + } + + /// A V1 create also registers a contract group and declares memberships + /// outside its contract; two creations that differ only there must not + /// summarize the same. + #[test] + fn a_data_contract_create_details_show_its_group_declarations() { + let created = get_data_contract_fixture( + Some(Identifier::from(OWNER)), + 1, + PlatformVersion::latest().protocol_version, + ); + let contract = DataContractInSerializationFormat::try_from_platform_versioned( + created.data_contract(), + PlatformVersion::latest(), + ) + .expect("serialization format"); + let create = |admin: [u8; 32], group: [u8; 32]| -> StateTransition { + DataContractCreateTransitionV1 { + data_contract: contract.clone(), + identity_nonce: 1, + contract_group: Some(ContractGroupRegistration { + admins: BTreeSet::from([Identifier::from(admin)]), + name: None, + description: None, + }), + contract_group_memberships: vec![ContractGroupMembership { + contract_group_id: Identifier::from(group), + member: ContractGroupMember::Contract, + }], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + } + .into() + }; + let details = |transition: &StateTransition| { + let StateTransitionSummaryKind::DataContractCreate(contract) = + summarize_bytes(transition).kind + else { + panic!("expected a contract create"); + }; + contract.details + }; + + let base = details(&create([0xA1; 32], [0xB1; 32])); + assert_ne!(base, details(&create([0xA2; 32], [0xB1; 32])), "admins"); + assert_ne!(base, details(&create([0xA1; 32], [0xB2; 32])), "membership"); + assert!(base.contains("contract_group_memberships"), "{base}"); + } + + /// dApp-chosen strings inside a config change (token names) and a + /// prefunded voting index name stay quoted inside their own field, so + /// delimiters and newlines cannot make two changes read the same or forge + /// a line. + #[test] + fn dapp_strings_in_details_stay_inside_their_field() { + let conventions = |singular: &str, plural: &str| { + BatchedTransition::Token(TokenTransition::ConfigUpdate( + TokenConfigUpdateTransition::V0(TokenConfigUpdateTransitionV0 { + base: token_base(), + update_token_configuration_item: TokenConfigurationChangeItem::Conventions( + TokenConfigurationConvention::V0(TokenConfigurationConventionV0 { + localizations: BTreeMap::from([( + "en".to_string(), + TokenConfigurationLocalization::V0( + TokenConfigurationLocalizationV0 { + should_capitalize: false, + singular_form: singular.to_string(), + plural_form: plural.to_string(), + }, + ), + )]), + decimals: 8, + }), + ), + public_note: None, + }), + )) + }; + let details = |transition: BatchedTransition| { + let StateTransitionSummaryKind::Batch { transitions, .. } = + summarize_bytes(&batch(vec![transition])).kind + else { + panic!("expected a batch"); + }; + transitions[0].details.clone().expect("details rendered") + }; + + let a = details(conventions("a', Plural: 'b", "c")); + let b = details(conventions("a", "b', Plural: 'c")); + assert_ne!(a, b); + let forged = details(conventions("x\npublic note: \"trusted\"", "y")); + assert_eq!(forged.lines().count(), 1, "{forged}"); + + let create = BatchedTransition::Document(DocumentTransition::Create( + DocumentCreateTransition::V0(DocumentCreateTransitionV0 { + base: document_base("post"), + entropy: [0xEE; 32], + data: BTreeMap::new(), + prefunded_voting_balance: Some(("idx\npublic note: \"x\"".to_string(), 1)), + }), + )); + let index = details(create); + assert!( + !index.lines().any(|line| line.starts_with("public note")), + "{index}" + ); + } + + /// Rendering stops at the first write past the budget and the summary is + /// refused, without formatting the rest or truncating it. + #[test] + fn details_beyond_the_budget_are_refused() { + use std::cell::Cell; + + /// Writes `chunks` pieces of 100 bytes, counting how many it wrote. + struct Chunks<'a> { + chunks: usize, + written: &'a Cell, + } + impl fmt::Debug for Chunks<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for _ in 0..self.chunks { + f.write_str(&"x".repeat(100))?; + self.written.set(self.written.get() + 1); + } + Ok(()) + } + } + + let written = Cell::new(0); + let mut budget = DetailsBudget { + limit: 1_000, + used: 0, + }; + let refused = budget.render(&Chunks { + chunks: 1_000, + written: &written, + }); + assert!(matches!( + refused, + Err(PlatformWalletError::InvalidParameter(message)) if message.contains("exceed") + )); + assert_eq!(written.get(), 10, "formatting stopped at the budget"); + + let mut budget = DetailsBudget::default(); + let data = Value::Array((0..1_000).map(Value::U64).collect()); + let rendered = budget.render(&data).expect("fits the default budget"); + assert_eq!(budget.used, rendered.len()); + } + + /// Labels, fixed text and the newlines joining lines are charged with the + /// values, so what is returned never exceeds the budget. + #[test] + fn labels_fixed_text_and_separators_are_charged() { + let render = |budget: &mut DetailsBudget| -> Result, PlatformWalletError> { + let lines = vec![ + budget.line("public note", &"hi")?, + budget.write(format_args!("issued to: the token's default destination"))?, + ]; + budget.join(lines) + }; + + let mut budget = DetailsBudget::default(); + let details = render(&mut budget).unwrap().expect("two lines"); + assert_eq!( + details, + "public note: \"hi\"\nissued to: the token's default destination" + ); + assert_eq!(budget.used, details.len()); + + // Exactly the two lines fit, but not the newline joining them. + let mut budget = DetailsBudget { + limit: details.len() - 1, + used: 0, + }; + assert!(matches!( + render(&mut budget), + Err(PlatformWalletError::InvalidParameter(message)) if message.contains("exceed") + )); + } + + /// Details are the escaped `Debug` output untouched: a document string + /// spelled like an identifier stays distinct from the identifier itself. + #[test] + fn identifier_shaped_strings_stay_distinct_from_identifiers() { + let create = |message: Value| { + BatchedTransition::Document(DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base: document_base("post"), + entropy: [0xEE; 32], + data: BTreeMap::from([("message".to_string(), message)]), + prefunded_voting_balance: None, + }, + ))) + }; + let details = |transition: BatchedTransition| { + let StateTransitionSummaryKind::Batch { transitions, .. } = + summarize_bytes(&batch(vec![transition])).kind + else { + panic!("expected a batch"); + }; + transitions[0].details.clone().expect("details rendered") + }; + let id = Identifier::from([0; 32]); + + let as_identifier = details(create(Value::Identifier(id.to_buffer()))); + let as_debug_text = details(create(Value::Text(format!("{id:?}")))); + let as_base58_text = details(create(Value::Text(format!("Identifier({id})")))); + assert_ne!(as_identifier, as_debug_text); + assert_ne!(as_identifier, as_base58_text); + assert_ne!(as_debug_text, as_base58_text); + } + + /// A document type name outside the consensus pattern is refused rather + /// than shown raw in its own field. + #[test] + fn invalid_document_type_names_are_refused() { + for name in ["post\u{202e}", "a\nb", "", &"x".repeat(65)] { + let transition = batch(vec![BatchedTransition::Document( + DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base: document_base(name), + entropy: [0xEE; 32], + data: BTreeMap::new(), + prefunded_voting_balance: None, + }, + )), + )]); + assert!( + summarize_state_transition(&transition.serialize_to_bytes().unwrap()).is_err(), + "{name:?}" + ); + } + } + + /// An added key bound to one document type shows that type's name in its + /// own field, so a name outside the consensus pattern is refused like a + /// batch row's; a valid one is summarized. + #[test] + fn invalid_document_type_bounds_of_added_keys_are_refused() { + let update = |document_type_name: &str| -> Vec { + let transition: StateTransition = IdentityUpdateTransitionV0 { + identity_id: Identifier::from([0x11; 32]), + revision: 1, + nonce: 1, + 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![0xbb; 65]), + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([0x22; 32]), + document_type_name: document_type_name.to_string(), + }), + total_budget: None, + expires_at: None, + } + .into()], + ..Default::default() + } + .into(); + transition.serialize_to_bytes().unwrap() + }; + + let summary = summarize_state_transition(&update("post")).expect("summarizes"); + assert!(summary.is_complete()); + for name in ["post\u{202e}", "a\nb", "", &"x".repeat(65)] { + assert!( + summarize_state_transition(&update(name)).is_err(), + "{name:?}" + ); + } + } + + /// A public note is quoted, so a newline cannot forge another line and a + /// NUL cannot break the C projection. + #[test] + fn public_notes_are_quoted() { + let summary = summarize_bytes(&batch(vec![BatchedTransition::Token( + TokenTransition::Transfer(TokenTransferTransition::V0(TokenTransferTransitionV0 { + base: token_base(), + amount: 1, + recipient_id: Identifier::from(RECIPIENT), + public_note: Some("hi\nrecipient: someone else\0".to_string()), + shared_encrypted_note: None, + private_encrypted_note: None, + })), + )])); + let StateTransitionSummaryKind::Batch { transitions, .. } = summary.kind else { + panic!("expected a batch"); + }; + let details = transitions[0].details.as_deref().expect("note rendered"); + assert_eq!(details.lines().count(), 1, "{details}"); + assert!(!details.contains('\0'), "{details}"); + } +} 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 f6e6c416907..9806b257692 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -272,6 +272,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>( 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 560891952b6..5602c73c255 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,99 @@ 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(); + } + // The FFI refuses any combination other than (6, 0), (7, 1), (7, 2). + if purpose < 0 { + throw_sdk_exception(env, 1, "purpose must be non-negative"); + 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 Ok(priv_arr) = env.byte_array_from_slice(&*scalar) else { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "connect key marshalling failed"); + return ptr::null_mut(); + }; + let build = (|| -> Result { + 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(); + // `Zeroizing` only wipes the Rust copy; overwrite the JVM copy of + // the private key too, since it never reaches Kotlin's cleanup. + let _ = env.set_byte_array_region(&priv_arr, 0, &[0i8; 32]); + 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 @@ -1139,3 +1233,39 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_update .unwrap_or(ptr::null_mut()) }) } + +#[cfg(test)] +mod tests { + use platform_wallet_ffi::derive_connect_key::{ + CONNECT_KEY_PURPOSE_DECRYPTION, CONNECT_KEY_PURPOSE_ENCRYPTION, + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + }; + + const PLATFORM_WALLET_MANAGER_KT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt" + )); + + /// Kotlin's `ConnectSubFeature` / `ConnectKeyPurpose` literals are the + /// FFI's constants; a drift would derive keys at paths nothing looks for. + #[test] + fn kotlin_connect_key_constants_match_the_ffi() { + for (entry, value) in [ + ( + "SESSION_AUTHENTICATION", + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + ), + ("APP_ENCRYPTION", CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION), + ("ENCRYPTION", CONNECT_KEY_PURPOSE_ENCRYPTION), + ("DECRYPTION", CONNECT_KEY_PURPOSE_DECRYPTION), + ] { + let needle = format!("{entry}({value}),"); + assert!( + PLATFORM_WALLET_MANAGER_KT + .lines() + .any(|line| line.trim() == needle), + "PlatformWalletManager.kt no longer declares `{needle}`" + ); + } + } +} 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..a1add5d6756 --- /dev/null +++ b/packages/rs-unified-sdk-jni/src/parse_state_transition.rs @@ -0,0 +1,789 @@ +//! 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_*) +//! u32 kind_name_len, u8[kind_name_len] kind_name (UTF-8) +//! u8 has_owner_id; if 1: u8[32] owner_id +//! u8 is_signed +//! u16 user_fee_increase (percentage added to the processing fee) +//! u8 complete (0 = render details / the rows' details before approval) +//! u32 serialized_len, u8[serialized_len] serialized (tagged DPP bytes) +//! u32 details_len, u8[details_len] details (UTF-8; the single-line Debug +//! dump of the whole transition for kinds 4 / 5 / 255, empty otherwise) +//! 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 +//! u8 has_token_count; if 1: u64 token_count (DirectPurchase) +//! u8 complete (0 = the typed fields do not cover every material +//! field; the row must not be approved without +//! rendering details) +//! u32 details_len, u8[details_len] details (UTF-8; empty when complete) +//! 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(()) +} + +/// Append `u32 len + bytes` for an optional C string; null encodes as +/// length 0 (`details` is null exactly when there is nothing to render). +/// +/// # Safety +/// `ptr` must be null or a valid NUL-terminated C string. +unsafe fn push_cstr32_opt( + blob: &mut Vec, + ptr: *const c_char, + what: &str, +) -> Result<(), String> { + let bytes: &[u8] = if ptr.is_null() { + &[] + } else { + CStr::from_ptr(ptr).to_bytes() + }; + push_count(blob, bytes.len(), what)?; + blob.extend_from_slice(bytes); + Ok(()) +} + +/// Append `u32 len + bytes` for a required C string. +/// +/// # Safety +/// `ptr` must be null or a valid NUL-terminated C string. +unsafe fn push_cstr32(blob: &mut Vec, ptr: *const c_char, what: &str) -> Result<(), String> { + if ptr.is_null() { + return Err(format!("{what} is null")); + } + push_cstr32_opt(blob, ptr, what) +} + +/// The `len` elements at `ptr`; a null pointer with a non-zero length is a +/// bug in the producing FFI. +/// +/// # Safety +/// `ptr` must be null or point to `len` initialized elements that outlive `'a`. +unsafe fn ffi_slice<'a, T>(ptr: *const T, len: usize, what: &str) -> Result<&'a [T], String> { + if len == 0 { + return Ok(&[]); + } + if ptr.is_null() { + return Err(format!("{what} pointer is null")); + } + Ok(std::slice::from_raw_parts(ptr, len)) +} + +/// # 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); + } + blob.push(u8::from(row.has_token_count)); + if row.has_token_count { + blob.extend_from_slice(&row.token_count.to_be_bytes()); + } + blob.push(u8::from(row.complete)); + push_cstr32_opt(blob, row.details, "batched transition details")?; + 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); + // u32: a batch's name lists every transition in it. + push_cstr32(&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)); + blob.extend_from_slice(&parsed.user_fee_increase.to_be_bytes()); + blob.push(u8::from(parsed.complete)); + let serialized = ffi_slice(parsed.serialized, parsed.serialized_len, "serialized")?; + push_count(&mut blob, serialized.len(), "serialized")?; + blob.extend_from_slice(serialized); + push_cstr32_opt(&mut blob, parsed.details, "details")?; + + match parsed.kind { + PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE => { + let update = &parsed.identity_update; + blob.extend_from_slice(&update.identity_id); + let keys = ffi_slice( + update.add_public_keys, + update.add_public_keys_count, + "added keys", + )?; + push_count(&mut blob, keys.len(), "added keys")?; + for key in keys { + push_parsed_public_key(&mut blob, key)?; + } + let disabled = ffi_slice( + update.disable_public_key_ids, + update.disable_public_key_ids_count, + "disabled key ids", + )?; + push_count(&mut blob, disabled.len(), "disabled keys")?; + for id in disabled { + blob.extend_from_slice(&id.to_be_bytes()); + } + } + PARSED_STATE_TRANSITION_KIND_BATCH => { + let batch = &parsed.batch; + blob.extend_from_slice(&batch.owner_id); + let rows = ffi_slice( + batch.transitions, + batch.transitions_count, + "batched transitions", + )?; + push_count(&mut blob, rows.len(), "batched transitions")?; + for row in rows { + 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); + let names = ffi_slice( + contract.document_type_names, + contract.document_type_names_count, + "document type names", + )?; + push_count(&mut blob, names.len(), "document type names")?; + for name in names { + 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::platform_value::platform_value; + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::token_transfer_transition::v0::TokenTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::{ + DocumentTransferTransition, DocumentTransition, + }; + 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::token_direct_purchase_transition::v0::TokenDirectPurchaseTransitionV0; + use dpp::state_transition::batch_transition::{ + DocumentCreateTransition, TokenDirectPurchaseTransition, + }; + use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use std::collections::BTreeMap; + 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(), + // A document-type-bound ENCRYPTION key: the bounds carry a + // variable-length document type name before the limits + // flags, so the field order after the string is pinned. + IdentityPublicKeyInCreationV0 { + id: 19, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + read_only: true, + data: BinaryData::new(vec![0x02; 33]), + signature: BinaryData::new(vec![]), + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([0x44; 32]), + document_type_name: "profile".to_string(), + }), + } + .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 str32(&mut self) -> String { + let len = self.u32() as usize; + String::from_utf8(self.take(len).to_vec()).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.str32(), "IdentityUpdate"); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x11; 32]); + assert_eq!(r.u8(), 0, "unsigned"); + assert_eq!(r.u16(), 0, "user fee increase"); + assert_eq!(r.u8(), 1, "complete"); + let serialized_len = r.u32() as usize; + assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.u32(), 0, "no details for a described kind"); + assert_eq!(r.take(32), &[0x11; 32]); + assert_eq!(r.u32(), 2); + 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); + // Second key: SingleContractDocumentType, whose document type name + // sits between the bounds id and the limits flags. + assert_eq!(r.u32(), 19); + assert_eq!(r.u8(), 0); // key type + assert_eq!(r.u8(), 1); // ENCRYPTION + assert_eq!(r.u8(), 3); // MEDIUM + assert_eq!(r.u8(), 1); // read only + assert_eq!(r.u8(), 2); // SingleContractDocumentType + assert_eq!(r.u16(), 33); + assert_eq!(r.take(33), &[0x02; 33]); + assert_eq!(r.take(32), &[0x44; 32]); + assert_eq!(r.str16(), "profile"); + assert_eq!(r.u8(), 0, "no limits after the document type name"); + 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.str32(), "DocumentsBatch([TokenTransfer])"); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u8(), 0); + assert_eq!(r.u16(), 0, "user fee increase"); + assert_eq!(r.u8(), 1, "complete"); + let serialized_len = r.u32() as usize; + assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.u32(), 0, "no details for a described kind"); + 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_eq!(r.u8(), 0, "no token count"); + assert_eq!(r.u8(), 1, "complete"); + assert_eq!(r.u32(), 0, "no details"); + assert!(r.0.is_empty(), "trailing bytes"); + + assert_eq!( + blob, TOKEN_TRANSFER_GOLDEN, + "token-transfer blob drifted from the golden shared with StateTransitionParserTest" + ); + } + + /// A document create (incomplete: its data renders in details), a + /// document transfer and a token direct purchase, so the blob exercises + /// both row families, the variable-length document type name and the + /// details string before the fields that follow them. + fn mixed_batch_bytes() -> Vec { + let base = |document_type_name: &str| { + 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([0x42; 32]), + }) + }; + StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from([0x21; 32]), + transitions: vec![ + BatchedTransition::Document(DocumentTransition::Create( + DocumentCreateTransition::V0(DocumentCreateTransitionV0 { + base: base("post"), + entropy: [0xEE; 32], + data: BTreeMap::from([("message".to_string(), platform_value!("hi"))]), + prefunded_voting_balance: None, + }), + )), + BatchedTransition::Document(DocumentTransition::Transfer( + DocumentTransferTransition::V0(DocumentTransferTransitionV0 { + base: base("profile"), + revision: 2, + recipient_owner_id: Identifier::from([0x22; 32]), + }), + )), + 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: None, + }), + token_count: 100, + total_agreed_price: 100_000_000, + }), + )), + ], + user_fee_increase: 7, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + })) + .serialize_to_bytes() + .expect("fixture mixed batch serializes") + } + + #[test] + fn mixed_batch_blob_round_trips_and_is_pinned_for_kotlin() { + let bytes = mixed_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.str32(), + "DocumentsBatch([Create, Transfer, TokenDirectPurchase])" + ); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u8(), 0); + assert_eq!(r.u16(), 7, "user fee increase"); + assert_eq!(r.u8(), 0, "incomplete: the create row renders its data"); + let serialized_len = r.u32() as usize; + assert_eq!(r.take(serialized_len), bytes.as_slice()); + assert_eq!(r.u32(), 0, "no common details for a described kind"); + assert_eq!(r.take(32), &[0x21; 32]); + assert_eq!(r.u32(), 3); + + // Document create: incomplete, data in details. + assert_eq!(r.u8(), 0); + assert_eq!(r.take(32), &[0x42; 32]); + assert_eq!(r.str16(), "Create"); + assert_eq!(r.str16(), "post"); + assert_eq!(r.take(32), &[0x0D; 32]); + assert_eq!(r.u8(), 0, "no amount"); + assert_eq!(r.u8(), 0, "no recipient"); + assert_eq!(r.u8(), 0, "no token count"); + assert_eq!(r.u8(), 0, "incomplete"); + let details_len = r.u32() as usize; + let details = String::from_utf8(r.take(details_len).to_vec()).unwrap(); + assert!(details.contains("\"message\""), "{details}"); + + // Document transfer: complete, recipient after the strings. + assert_eq!(r.u8(), 0); + assert_eq!(r.take(32), &[0x42; 32]); + assert_eq!(r.str16(), "Transfer"); + assert_eq!(r.str16(), "profile"); + assert_eq!(r.take(32), &[0x0D; 32]); + assert_eq!(r.u8(), 0, "no amount"); + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x22; 32]); + assert_eq!(r.u8(), 0, "no token count"); + assert_eq!(r.u8(), 1, "complete"); + assert_eq!(r.u32(), 0, "no details"); + + // Direct purchase: amount, token count, complete. + assert_eq!(r.u8(), 1); + assert_eq!(r.take(32), &[0x42; 32]); + assert_eq!(r.str16(), "DirectPurchase"); + assert_eq!(r.u16(), 3); + assert_eq!(r.take(32), &[0x77; 32]); + assert_eq!(r.u8(), 1); + assert_eq!(r.u64(), 100_000_000); + assert_eq!(r.u8(), 0, "no recipient"); + assert_eq!(r.u8(), 1); + assert_eq!(r.u64(), 100); + assert_eq!(r.u8(), 1, "complete"); + assert_eq!(r.u32(), 0, "no details"); + assert!(r.0.is_empty(), "trailing bytes"); + + assert_eq!( + blob, MIXED_BATCH_GOLDEN, + "mixed-batch blob drifted from the golden shared with StateTransitionParserTest" + ); + } + + /// A direct purchase whose agreed price ceiling is `u64::MAX`: the blob carries the full + /// unsigned value, which the Kotlin reader must not see as a negative `Long`. + #[test] + fn max_price_purchase_blob_carries_the_unsigned_price() { + let bytes = StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: Identifier::from([0x21; 32]), + transitions: 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: None, + }), + token_count: u64::MAX, + total_agreed_price: u64::MAX, + }), + ))], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + })) + .serialize_to_bytes() + .expect("fixture batch serializes"); + let blob = parse_to_blob(&bytes); + assert_eq!( + blob, MAX_PRICE_PURCHASE_GOLDEN, + "max-price 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" + )); + const MIXED_BATCH_GOLDEN: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_mixed_batch_v1.bin" + )); + const MAX_PRICE_PURCHASE_GOLDEN: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/parsed_max_price_purchase_v1.bin" + )); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 587fe126494..3031e5ee433 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -186,7 +186,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 @@ -254,7 +254,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] @@ -270,49 +270,252 @@ 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. + /// 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`. + case document(Document) + /// A token transition on `tokenId` at `tokenContractPosition` of + /// `dataContractId`. + case token(Token) + + /// A document transition. `amount` is a purchase price or an + /// update-price value; `recipientId` is a transfer's new owner. + public struct Document: Sendable, Equatable { + public let dataContractId: Identifier + public let documentType: String + public let documentId: Identifier + public let action: String + public let amount: UInt64? + public let recipientId: Identifier? + /// Whether the fields above describe every material field. A + /// `Create`, `Replace` or `IndexOnlyDelete` carries document + /// data that has no typed projection, so it is never complete. + public let complete: Bool + /// The material fields not covered above (the document data, a + /// prefunded voting balance), rendered for display; `nil` when + /// `complete`. **A row with `complete == false` must not be + /// approved from the typed fields alone; render `details`.** + public let details: String? + + public init( + dataContractId: Identifier, documentType: String, documentId: Identifier, + action: String, amount: UInt64?, recipientId: Identifier?, + complete: Bool, details: String? + ) { + self.dataContractId = dataContractId + self.documentType = documentType + self.documentId = documentId + self.action = action + self.amount = amount + self.recipientId = recipientId + self.complete = complete + self.details = details + } + } + + /// 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. Interpret both against `action`: a `Freeze` sheet + /// says "freeze the tokens of X", not "send to X". `tokenCount` + /// is a `DirectPurchase`'s token count. + public struct Token: Sendable, Equatable { + public let dataContractId: Identifier + public let tokenId: Identifier + public let tokenContractPosition: UInt16 + public let action: String + public let amount: UInt64? + public let recipientId: Identifier? + public let tokenCount: UInt64? + /// Whether the fields above describe every material field. + /// `ConfigUpdate`, `EmergencyAction`, `SetPriceForDirectPurchase` + /// and `Claim` never are, nor is any transition carrying a note + /// or group-action info. + public let complete: Bool + /// The material fields not covered above (the config change and + /// who it grants or revokes, the emergency action, the price + /// schedule, the distribution type, notes, group action), + /// rendered for display; `nil` when `complete`. **A row with + /// `complete == false` must not be approved from the typed + /// fields alone; render `details`.** + public let details: String? + + public init( + dataContractId: Identifier, tokenId: Identifier, tokenContractPosition: UInt16, + action: String, amount: UInt64?, recipientId: Identifier?, tokenCount: UInt64?, + complete: Bool, details: String? + ) { + self.dataContractId = dataContractId + self.tokenId = tokenId + self.tokenContractPosition = tokenContractPosition + self.action = action + self.amount = amount + self.recipientId = recipientId + self.tokenCount = tokenCount + self.complete = complete + self.details = details + } + } + + /// The data contract the transition acts within. + public var dataContractId: Identifier { + switch self { + case .document(let row): return row.dataContractId + case .token(let row): return row.dataContractId + } + } + + /// The rs-dpp action name. + public var action: String { + switch self { + case .document(let row): return row.action + case .token(let row): return row.action + } + } + + /// Whether the typed fields describe every material field. When + /// `false`, `details` must be rendered before approval. + public var complete: Bool { + switch self { + case .document(let row): return row.complete + case .token(let row): return row.complete + } + } + + /// The rendered material fields the typed fields do not cover. + public var details: String? { + switch self { + case .document(let row): return row.details + case .token(let row): return row.details + } + } + } + + /// Inspectable fields of a parsed `BatchTransition`. + public struct ParsedBatchTransition: Sendable, Equatable { + /// The identity whose keys sign and whose credits pay. 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 + /// One entry per batched transition, in order. + public let transitions: [ParsedBatchedTransition] - public init( - ownerId: Identifier, - dataContractId: Identifier, - tokenId: Identifier, - tokenContractPosition: UInt16, - tokenCount: UInt64, - totalAgreedPrice: UInt64 - ) { + 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 + } + } + + /// 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 } } - /// 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 `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` with + /// the decoded transition in `ParsedStateTransition.details`. + /// + /// Nothing here may be treated as ready to sign unless + /// `ParsedStateTransition.complete` is `true`; otherwise render its + /// `details` and each `.batch` row's `details`. + 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 decoded transition re-serialized in tagged + /// DPP framing; 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 + /// Percentage added to the processing fee Platform charges (`0` = + /// none; `65535` is about 656 times the base processing fee). Part + /// of the signed bytes: show a non-zero value on the sheet, and + /// refuse values above what the wallet is willing to pay. + public let userFeeIncrease: UInt16 + /// Whether `kind` shows every material field (computed in Rust). + /// When `false`, render `details` and the batch rows' `details`. + public let complete: Bool + /// The decoded bytes, tagged. + public let serialized: Data + /// A single-line dump with every dApp-controlled string quoted: of + /// the whole transition for `.other` and for a data contract create + /// or update. `nil` otherwise. + public let details: String? + /// The typed summary. + public let kind: ParsedStateTransitionKind + + public init( + kindName: String, + ownerId: Identifier?, + isSigned: Bool, + userFeeIncrease: UInt16, + complete: Bool, + serialized: Data, + details: String?, + kind: ParsedStateTransitionKind + ) { + self.kindName = kindName + self.ownerId = ownerId + self.isSigned = isSigned + self.userFeeIncrease = userFeeIncrease + self.complete = complete + self.serialized = serialized + self.details = details + self.kind = kind + } } /// Result of a successful identity registration. @@ -1274,16 +1477,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) } @@ -1317,6 +1526,147 @@ 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: Sendable { + /// Session authentication key: the leaf is the connect request id + /// (`hash256` of the app's ephemeral public key); no purpose level. + case sessionAuthentication + /// App encryption key pair: the leaf is the id of the data + /// contract the key is bound to; the purpose level is + /// `ConnectKeyPurpose.encryption` or `.decryption`. + case appEncryption + + /// The DIP-13 sub-feature index the FFI derives under. + var ffiValue: UInt32 { + switch self { + case .sessionAuthentication: return UInt32(CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION) + case .appEncryption: return UInt32(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION) + } + } + } + + /// 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: Sendable { + case encryption + case decryption + + /// The DPP `KeyPurpose` the derived half is registered under. + public var keyPurpose: KeyPurpose { + switch self { + case .encryption: return .encryption + case .decryption: return .decryption + } + } + + /// The trailing `purpose'` level the FFI appends. + var ffiValue: UInt32 { + switch self { + case .encryption: return UInt32(CONNECT_KEY_PURPOSE_ENCRYPTION) + case .decryption: return UInt32(CONNECT_KEY_PURPOSE_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. `.appEncryption` requires a `purpose`, + /// which picks the half of its pair; `.sessionAuthentication` requires + /// `nil`. Any other combination throws `invalidParameter`. + /// + /// - 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.ffiValue, + identityBytes.bindMemory(to: UInt8.self).baseAddress!, + leafBytes.bindMemory(to: UInt8.self).baseAddress!, + purpose?.ffiValue ?? 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. /// @@ -3525,21 +3875,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( @@ -3562,33 +3910,196 @@ extension ManagedPlatformWallet { try result.check() defer { platform_wallet_parse_state_transition_free(&out) } - switch out.kind { - case 1: // PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE - return .identityUpdate( - try Self.makeParsedIdentityUpdateTransition(from: out.identity_update) + 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 UInt8(PARSED_STATE_TRANSITION_KIND_IDENTITY_UPDATE): + kind = .identityUpdate( + try makeParsedIdentityUpdateTransition(from: ffi.identity_update) + ) + case UInt8(PARSED_STATE_TRANSITION_KIND_BATCH): + kind = .batch(try makeParsedBatchTransition(from: ffi.batch)) + case UInt8(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 UInt8(PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_CREATE): + kind = .dataContractCreate( + try makeParsedDataContractTransition(from: ffi.data_contract) + ) + case UInt8(PARSED_STATE_TRANSITION_KIND_DATA_CONTRACT_UPDATE): + kind = .dataContractUpdate( + try makeParsedDataContractTransition(from: ffi.data_contract) + ) + case UInt8(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" ) - 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 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() + } + + let details: String? + if let detailsPtr = ffi.details { + guard let rendered = String(validatingCString: detailsPtr) else { + throw PlatformWalletError.deserialization( + "Parsed state transition details are not valid UTF-8" ) + } + details = rendered + } else { + details = nil + } + + return ParsedStateTransition( + kindName: kindName, + ownerId: ownerId, + isSigned: ffi.is_signed, + userFeeIncrease: ffi.user_fee_increase, + complete: ffi.complete, + serialized: serialized, + details: details, + 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" ) + } + 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 + let details: String? + if let detailsPtr = entry.details { + guard let rendered = String(validatingCString: detailsPtr) else { + throw PlatformWalletError.deserialization( + "Batched transition \(index) details are not valid UTF-8" + ) + } + details = rendered + } else { + details = nil + } + + switch entry.family { + case UInt8(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( + .init( + dataContractId: dataContractId, + documentType: documentType, + documentId: tupleData(entry.document_id), + action: action, + amount: amount, + recipientId: recipientId, + complete: entry.complete, + details: details + )) + case UInt8(PARSED_BATCHED_TRANSITION_FAMILY_TOKEN): + return .token( + .init( + dataContractId: dataContractId, + tokenId: tupleData(entry.token_id), + tokenContractPosition: entry.token_contract_position, + action: action, + amount: amount, + recipientId: recipientId, + tokenCount: entry.has_token_count ? entry.token_count : nil, + complete: entry.complete, + details: details + )) 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 { @@ -3624,7 +4135,7 @@ extension ManagedPlatformWallet { ) } - private static func makeParsedIdentityPubkey( + static func makeParsedIdentityPubkey( from entry: ParsedIdentityUpdatePublicKeyFFI, index: Int ) throws -> IdentityPubkey { @@ -3656,7 +4167,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/credit_transfer_max_fee.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer_max_fee.hex new file mode 100644 index 00000000000..a318fa552d5 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_transfer_max_fee.hex @@ -0,0 +1 @@ +070011111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222fb03e801fbffff0000 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_withdrawal.hex b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_withdrawal.hex new file mode 100644 index 00000000000..335e94089b5 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/credit_withdrawal.hex @@ -0,0 +1 @@ +05011111111111111111111111111111111111111111111111111111111111111111fc075bcd150700010476a914ab03000000 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..8f453a44c2e --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_create.hex @@ -0,0 +1 @@ +000001e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f13502000000000101000001000121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f7065727469657313000000000000000000000001000000 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..72fe6818161 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/StateTransitions/data_contract_update.hex @@ -0,0 +1 @@ +01000201e34025f57305730e5c3c89c5536222298623ff9e4348c4e56d82c5bb1538f13502000000000101000001000121212121212121212121212121212121212121212121212121212121212121210101086c6173744e616d6516011204747970651206737472696e67070f696e6465786564446f63756d656e74160512047479706512066f626a6563741207696e64696365731506160312046e616d651206696e64657831120a70726f70657274696573150216011208246f776e6572496412036173631601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150216011208246f776e657249641203617363160112086c6173744e616d6512036173631206756e697175651301160212046e616d651206696e64657833120a70726f706572746965731501160112086c6173744e616d651203617363160212046e616d651206696e64657834120a70726f7065727469657315021601120a2463726561746564417412036173631601120a247570646174656441741203617363160212046e616d651206696e64657835120a70726f7065727469657315011601120a247570646174656441741203617363160212046e616d651206696e64657836120a70726f7065727469657315011601120a246372656174656441741203617363120a70726f706572746965731602120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e0502120872657175697265641504120966697273744e616d65120a24637265617465644174120a2475706461746564417412086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000c6e696365446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e0500120872657175697265641501120a2463726561746564417412146164646974696f6e616c50726f7065727469657313000e6e6f54696d65446f63756d656e74160312047479706512066f626a656374120a70726f70657274696573160112046e616d6516021204747970651206737472696e671208706f736974696f6e050012146164646974696f6e616c50726f7065727469657313001d6f7074696f6e616c556e69717565496e6465786564446f63756d656e74160512047479706512066f626a656374120a70726f706572746965731604120966697273744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050012086c6173744e616d6516031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05021207636f756e74727916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e050412046369747916031204747970651206737472696e6712096d61784c656e677468043f1208706f736974696f6e05061207696e64696365731503160312046e616d651206696e64657831120a70726f7065727469657315011601120966697273744e616d6512036173631206756e697175651301160312046e616d651206696e64657832120a70726f70657274696573150316011208246f776e6572496412036173631601120966697273744e616d651203617363160112086c6173744e616d6512036173631206756e697175651301160312046e616d651206696e64657833120a70726f70657274696573150216011207636f756e7472791203617363160112046369747912036173631206756e697175651301120872657175697265641502120966697273744e616d6512086c6173744e616d6512146164646974696f6e616c50726f7065727469657313000e707265747479446f63756d656e74160412047479706512066f626a656374120a70726f70657274696573160112086c6173744e616d6516021204247265661210232f24646566732f6c6173744e616d651208706f736974696f6e050012087265717569726564150212086c6173744e616d65120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000b756e697175654461746573160512047479706512066f626a6563741207696e64696365731502160312046e616d651206696e64657831120a70726f7065727469657315021601120a2463726561746564417412036173631601120a2475706461746564417412036173631206756e697175651301160212046e616d651206696e64657832120a70726f7065727469657315011601120a247570646174656441741203617363120a70726f706572746965731602120966697273744e616d6516021204747970651206737472696e671208706f736974696f6e050012086c6173744e616d6516021204747970651206737472696e671208706f736974696f6e0502120872657175697265641503120966697273744e616d65120a24637265617465644174120a2475706461746564417412146164646974696f6e616c50726f7065727469657313000e7769746842797465417272617973160512047479706512066f626a6563741207696e64696365731501160212046e616d651206696e64657831120a70726f7065727469657315011601120e6279746541727261794669656c641203617363120a70726f706572746965731602120e6279746541727261794669656c641604120474797065120561727261791209627974654172726179130112086d61784974656d7304101208706f736974696f6e0500120f6964656e7469666965724669656c64160612047479706512056172726179120962797465417272617913011210636f6e74656e744d656469615479706512216170706c69636174696f6e2f782e646173682e6470702e6964656e74696669657212086d696e4974656d73042012086d61784974656d7304201208706f736974696f6e0502120872657175697265641501120e6279746541727261794669656c6412146164646974696f6e616c50726f70657274696573130000000000000000000000000000 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..2499e804100 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ParseStateTransitionTests.swift @@ -0,0 +1,232 @@ +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) + + // The document create carries data with no typed projection, so the + // row is incomplete and renders it. + guard case .document(let create) = batch.transitions[0] else { + return XCTFail("expected a document row") + } + XCTAssertEqual(create.action, "Create") + XCTAssertEqual(create.documentType, "post") + XCTAssertEqual(create.dataContractId, contract) + XCTAssertEqual(create.documentId, Data(repeating: 0x0D, count: 32)) + XCTAssertNil(create.amount) + XCTAssertNil(create.recipientId) + XCTAssertFalse(create.complete) + XCTAssertTrue(create.details?.contains("\"message\"") == true, "\(create.details ?? "")") + XCTAssertFalse(batch.transitions[0].complete) + + XCTAssertEqual( + batch.transitions[1], + .document( + .init( + dataContractId: contract, documentType: "profile", + documentId: Data(repeating: 0x0D, count: 32), action: "Transfer", + amount: nil, recipientId: recipient, complete: true, details: nil))) + XCTAssertEqual( + batch.transitions[2], + .token( + .init( + dataContractId: contract, tokenId: token, tokenContractPosition: 3, + action: "Transfer", amount: 250, recipientId: recipient, tokenCount: nil, + complete: true, details: nil))) + XCTAssertEqual( + batch.transitions[3], + .token( + .init( + dataContractId: contract, tokenId: token, tokenContractPosition: 3, + action: "DirectPurchase", amount: 100_000_000, recipientId: nil, + tokenCount: 100, complete: true, details: nil))) + XCTAssertEqual(parsed.userFeeIncrease, 1) + XCTAssertFalse(parsed.complete, "the create row renders its data") + XCTAssertNil(parsed.details, "described kinds carry no common details") + 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.userFeeIncrease, 0) + XCTAssertEqual( + parsed.kind, + .creditTransfer( + ParsedCreditTransferTransition( + identityId: Data(repeating: 0x11, count: 32), + recipientId: recipient, + amount: 1_000))) + XCTAssertTrue(parsed.complete) + XCTAssertNil(parsed.details) + } + + 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) + XCTAssertFalse(parsed.complete, "tokens, groups and schemas are in details") + XCTAssertTrue(parsed.details?.contains("niceDocument") == true) + + 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", + ]) + } + } + + /// The user fee increase scales the processing fee and is part of the + /// signed bytes, so a maxed-out transfer must not parse to the same + /// approval fields as a plain one. Both fixtures are the same credit + /// transfer, serialized on the Rust side with `user_fee_increase` 0 and + /// 65535. + func testUserFeeIncreaseIsExposed() throws { + let plain = try wallet.parseStateTransition(try fixture("credit_transfer")) + let maxed = try wallet.parseStateTransition(try fixture("credit_transfer_max_fee")) + XCTAssertEqual(plain.userFeeIncrease, 0) + XCTAssertEqual(maxed.userFeeIncrease, 65535) + guard case .creditTransfer(let a) = plain.kind, case .creditTransfer(let b) = maxed.kind + else { return XCTFail("expected credit transfers") } + XCTAssertEqual(a, b, "everything but the fee multiplier is identical") + XCTAssertNotEqual(plain, maxed, "the parsed results differ in the multiplier") + } + + /// Kinds without a describer carry the decoded transition as a + /// structured dump so a withdrawal's amount and destination can be shown. + func testOtherKindCarriesStructuredDetails() throws { + let parsed = try wallet.parseStateTransition(try fixture("credit_withdrawal")) + XCTAssertEqual(parsed.kindName, "IdentityCreditWithdrawal") + XCTAssertEqual(parsed.kind, .other) + XCTAssertFalse(parsed.complete) + let details = try XCTUnwrap(parsed.details) + XCTAssertTrue(details.contains("amount: 123456789"), details) + XCTAssertTrue(details.contains("output_script"), details) + } + + 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