Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
af89edf
feat(kotlin-sdk): wire-compatible encrypted txMetadata document creat…
bfoss765 Jul 10, 2026
5675e9a
test(platform-wallet): pin txMetadata wire-compat to a dashj-generate…
bfoss765 Jul 11, 2026
0e04078
test(platform-wallet): pin encrypted-txMetadata FETCH to the real tes…
bfoss765 Jul 11, 2026
1bc6780
debug(platform-wallet): warn-level stage breadcrumbs on the encrypted…
bfoss765 Jul 11, 2026
062eef3
fix(platform-wallet): dual-emit encrypted-document breadcrumbs throug…
bfoss765 Jul 11, 2026
2fd8fcc
fix(platform-wallet): derive txMetadata keys through the mnemonic res…
bfoss765 Jul 11, 2026
98f1e66
fix(platform-wallet): nonzero-identity wire vector; keep master xprv …
bfoss765 Jul 11, 2026
6171806
perf(platform-wallet): share the fetched DataContract Arc instead of …
bfoss765 Jul 12, 2026
39ce39b
fix(kotlin-sdk): reject txMetadata version bytes the legacy stack can…
bfoss765 Jul 12, 2026
9108979
fix(platform-wallet): enforce txMetadata wire version (0|1) in Rust c…
bfoss765 Jul 12, 2026
73fbcd1
docs(txmetadata): scope legacy wire-compat to identity_index=0; relab…
bfoss765 Jul 12, 2026
4dd403d
test(txmetadata): check in a real-DerivationPathFactory provenance ve…
bfoss765 Jul 12, 2026
1c145d3
fix(kotlin-sdk): gate encrypted-document create/fetch through the Tea…
bfoss765 Jul 16, 2026
7a33a45
fix(platform-wallet): pre-check txMetadata payload size before deriva…
bfoss765 Jul 21, 2026
f7233bf
fix(platform-wallet-ffi): zeroize + drop txMetadata plaintext before …
bfoss765 Jul 21, 2026
9c67ba8
docs(txmetadata): mark the fetch regression test as a manual/testnet-…
bfoss765 Jul 21, 2026
1ffdb24
chore(txmetadata): strip internal tracker tokens from comments
bfoss765 Jul 21, 2026
05f7294
test(platform-wallet): anchor ENCRYPTED_METADATA_FIELD_MAX to the con…
bfoss765 Jul 21, 2026
3971254
test(txmetadata): pin an independent legacy-INSTALL wire-compat vector
bfoss765 Jul 21, 2026
a44b346
fix(kotlin-sdk): zeroize + early-drop the JNI txMetadata plaintext copy
bfoss765 Jul 22, 2026
ae5e83b
style: cargo fmt (#4186)
bfoss765 Jul 23, 2026
f1137f4
feat(platform-wallet): allocate txMetadata encryptionKeyIndex in Rust…
bfoss765 Jul 21, 2026
658c026
docs(keyindex): note allocator serialization and failure-gap trade-offs
bfoss765 Jul 21, 2026
df5c9a8
fix(platform-wallet): validate txMetadata payload size before allocat…
bfoss765 Jul 22, 2026
657b25d
refactor(platform-wallet): generate txMetadata indices locally
QuantumExplorer Aug 4, 2026
bfc9de2
fix(jni): avoid global exception logging
QuantumExplorer Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

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

Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package org.dashfoundation.dashsdk.documents

import org.dashfoundation.dashsdk.wallet.op

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.dashfoundation.dashsdk.errors.mapNativeErrors
import org.dashfoundation.dashsdk.ffi.TransactionsNative

Expand Down Expand Up @@ -251,4 +248,131 @@ class DocumentTransactions internal constructor(
)
}
}

/**
* Create + broadcast an ENCRYPTED wallet-contract document (the wire-
* compatible `txMetadata` shape) on [contractId]'s [documentType], owned by
* [ownerId] — signed via [signerHandle]. Implements the create half of the
* legacy `BlockchainIdentity.publishTxMetaData` retirement
* (dashpay/platform#4086): the SDK derives the identity encryption key,
* seals [payload] into the legacy `version ‖ IV ‖ AES-256-CBC` blob, and
* writes `{keyIndex, encryptionKeyIndex, encryptedMetadata}`.
*
* Batching stays app-side: the caller serializes its items into [payload]
* (a protobuf `TxMetadataBatch`). The identity encryption key id (the
* `keyIndex` field) is chosen SDK-side to match the legacy stack, so the key
* never crosses the FFI boundary.
*
* ### `encryptionKeyIndex` selection (dashpay/platform#4186 follow-up)
* Leave [encryptionKeyIndex] `null` (the default) to let the SDK generate
* the per-document index in Rust — the host-thin path. Rust draws a valid
* non-zero 31-bit BIP-32 child index from the operating-system CSPRNG. The
* index is a derivation input stored on each document, not a protocol
* sequence number; a repeated index is non-lossy because each document also
* has a fresh IV and readers derive from that document's stored fields.
*
* Passing an explicit non-negative [encryptionKeyIndex] is retained ONLY for
* migration / tests and is discouraged: hosts should not own derivation
* index policy.
*
* @param encryptionKeyIndex `null` to let the SDK generate the index
* (preferred); or an explicit non-negative per-document index
* (migration / tests only).
* @param version payload version byte (`1` = protobuf, as the wallet writes).
* @param payload already-serialized opaque plaintext; the SDK does not
* parse it.
* [mnemonicResolverHandle] is the host mnemonic-resolver handle
* ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]):
* required for external-signable wallets (the app's shape — the AES key
* derives on demand through the resolver), ignored for wallets with
* resident private keys.
*
* @return the confirmed document's canonical JSON (its 32-byte id is the
* base58 `$id` field).
*/
suspend fun createEncryptedDocument(
walletHandle: Long,
mnemonicResolverHandle: Long,
ownerId: ByteArray,
contractId: ByteArray,
documentType: String,
version: Int,
payload: ByteArray,
signerHandle: Long,
encryptionKeyIndex: Int? = null,
): String = gate.op {
require(ownerId.size == 32) { "ownerId must be 32 bytes" }
require(contractId.size == 32) { "contractId must be 32 bytes" }
require(encryptionKeyIndex == null || encryptionKeyIndex >= 0) {
"encryptionKeyIndex, when supplied, must be non-negative, got $encryptionKeyIndex"
}
// Only 0 (CBOR) and 1 (protobuf) are wire-meaningful: `seal_tx_metadata`
// writes this byte verbatim into the envelope and the legacy dashj stack
// (decryptTxMetadata) switches on exactly those two values. Accepting 2..255
// would silently seal a document the legacy stack can't decode, breaking the
// bidirectional wire-compat guarantee (dashpay/platform#4091).
require(version == 0 || version == 1) {
"version must be 0 (CBOR) or 1 (protobuf), got $version"
}
mapNativeErrors {
TransactionsNative.documentCreateEncrypted(
walletHandle,
mnemonicResolverHandle,
ownerId,
contractId,
documentType,
// -1 is the JNI sentinel for "let Rust generate the index".
encryptionKeyIndex ?: -1,
version,
payload,
signerHandle,
)
}
}

/**
* Fetch + DECRYPT every encrypted wallet-contract document owned by
* [ownerId] on [contractId]'s [documentType] updated at or after [sinceMs]
* (epoch-millis). Implements the read half of the legacy
* `BlockchainIdentity.getTxMetaData(since, key)` retirement
* (dashpay/platform#4087): the SDK fetches the owner-scoped, since-timestamp
* documents and decrypts each with the identity's derived key. Documents
* that fail to decrypt are skipped Rust-side (a bad document never aborts
* the fetch).
*
* @return a JSON array; each element is `{ "id", "ownerId" (base58),
* "keyIndex", "encryptionKeyIndex", "version", "updatedAt" (number|null),
* "payload" (base64 of the decrypted opaque plaintext) }`. The caller
* parses each `payload` itself (a protobuf `TxMetadataBatch` for
* `version == 1`) and reconciles memo / taxCategory / exchangeRate /
* service / giftCard fields into its local store.
*
* [mnemonicResolverHandle] is the host mnemonic-resolver handle
* ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]):
* required for external-signable wallets (the app's shape — the AES key
* derives on demand through the resolver), ignored for wallets with
* resident private keys.
*/
suspend fun fetchEncryptedDocuments(
walletHandle: Long,
mnemonicResolverHandle: Long,
ownerId: ByteArray,
contractId: ByteArray,
documentType: String,
sinceMs: Long,
): String = gate.op {
require(ownerId.size == 32) { "ownerId must be 32 bytes" }
require(contractId.size == 32) { "contractId must be 32 bytes" }
require(sinceMs >= 0) { "sinceMs must be non-negative, got $sinceMs" }
mapNativeErrors {
TransactionsNative.documentFetchEncrypted(
walletHandle,
mnemonicResolverHandle,
ownerId,
contractId,
documentType,
sinceMs,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,71 @@ internal object TransactionsNative {
signerHandle: Long,
): String

/**
* Create + broadcast an ENCRYPTED wallet-contract document (the wire-
* compatible `txMetadata` shape) on [contractId]'s [documentType], owned by
* [ownerId], signed via [signerHandle]. Bridges
* `platform_wallet_create_encrypted_document_with_signer`.
*
* The Rust side selects the identity's ENCRYPTION key id (the `keyIndex`
* field), derives the AES key from the wallet HD tree, and seals [payload]
* into the legacy `version ‖ IV ‖ AES-256-CBC` blob — decryptable by the
* legacy `org.dashj.platform` stack and vice versa.
*
* @param mnemonicResolverHandle the host mnemonic-resolver handle
* ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]);
* required (non-zero) for external-signable wallets — the app's shape —
* whose txMetadata AES key derives on demand through the resolver.
* Ignored for wallets with resident private keys.
* @param encryptionKeyIndex the per-document index, OR `-1` to let the SDK
* generate it in Rust with the operating-system CSPRNG. A non-negative
* value routes to the explicit-index FFI export (migration / tests);
* `-1` routes to
* `platform_wallet_create_encrypted_document_with_signer_auto_index`,
* which omits the index. Values `< -1` are rejected.
* @param version payload version byte (`1` = protobuf, as the wallet writes).
* @param payload the already-serialized opaque plaintext (a protobuf
* `TxMetadataBatch`); the SDK does not parse it.
* @return the confirmed document's canonical JSON (its 32-byte id is the
* base58 `$id` field).
*/
external fun documentCreateEncrypted(
walletHandle: Long,
mnemonicResolverHandle: Long,
ownerId: ByteArray,
contractId: ByteArray,
documentType: String,
encryptionKeyIndex: Int,
version: Int,
payload: ByteArray,
signerHandle: Long,
): String

/**
* Fetch + DECRYPT every encrypted wallet-contract document owned by
* [ownerId] on [contractId]'s [documentType] updated at or after [sinceMs]
* (epoch-millis). Bridges `platform_wallet_fetch_encrypted_documents` — the
* wire-compatible read counterpart of the legacy `getTxMetaData(since, key)`.
*
* @param mnemonicResolverHandle the host mnemonic-resolver handle
* ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]);
* required (non-zero) for external-signable wallets — the app's shape —
* whose txMetadata AES key derives on demand through the resolver.
* Ignored for wallets with resident private keys.
* @return a JSON array; each element is `{ "id", "ownerId" (base58),
* "keyIndex", "encryptionKeyIndex", "version", "updatedAt" (number|null),
* "payload" (base64 of the decrypted opaque plaintext) }`. Documents that
* fail to decrypt are skipped Rust-side.
*/
external fun documentFetchEncrypted(
walletHandle: Long,
mnemonicResolverHandle: Long,
ownerId: ByteArray,
contractId: ByteArray,
documentType: String,
sinceMs: Long,
): String

/**
* Cast a masternode contested-resource vote and wait for the response.
* Bridges `dash_sdk_contested_resource_cast_vote` (Swift
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package org.dashfoundation.dashsdk.documents

import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* Input validation for [DocumentTransactions.createEncryptedDocument].
*
* Two independent guards are exercised here, both of which run BEFORE any
* native call (`TransactionsNative`), so the REJECTION paths are testable on the
* JVM without the JNI library loaded:
*
* 1. Version byte (dashpay/platform#4091): only 0 (CBOR) and 1 (protobuf) are
* wire-meaningful — `seal_tx_metadata` writes the byte verbatim and the
* legacy dashj `decryptTxMetadata` switches on exactly those two values, so
* an out-of-range byte would silently seal a document the legacy stack can't
* decode.
* 2. `encryptionKeyIndex` (dashpay/platform#4186 follow-up): `null` is the
* preferred path (Rust generates the per-document index); an explicit
* value, when supplied, must be non-negative.
*
* Paths that PASS validation proceed into native and can't be fully unit-tested
* here (no JNI library); [noIndexPathPassesValidation] asserts only that the
* `null` index is accepted by the guard, not rejected as an argument error.
*/
class DocumentTransactionsVersionValidationTest {

private val id32 = ByteArray(32)
private val payload = ByteArray(4) { it.toByte() }

private fun createWithVersion(version: Int) = runBlocking {
DocumentTransactions().createEncryptedDocument(
walletHandle = 0L,
mnemonicResolverHandle = 0L,
ownerId = id32,
contractId = id32,
documentType = "txMetadata",
version = version,
payload = payload,
signerHandle = 0L,
encryptionKeyIndex = 0,
)
}

/** Bytes 2..255 (previously accepted by the `0..255` range) are now rejected. */
@Test
fun rejectsVersionBytesTheLegacyStackCannotDecode() {
for (version in intArrayOf(2, 3, 127, 255)) {
val e = assertThrows(
"version=$version must be rejected",
IllegalArgumentException::class.java,
) { createWithVersion(version) }
assertTrue(
"message should name the wire-meaningful versions, got: ${e.message}",
e.message!!.contains("0 (CBOR) or 1 (protobuf)"),
)
}
}

/** A negative version byte is likewise rejected. */
@Test
fun rejectsNegativeVersion() {
assertThrows(IllegalArgumentException::class.java) { createWithVersion(-1) }
}

/**
* An explicit NEGATIVE index (the migration/test-only path) is rejected by
* the `require`. `null` (the generate-in-Rust path) is the only way to omit
* an index; a negative explicit value is a caller error.
*/
@Test
fun rejectsExplicitNegativeIndex() {
val e = assertThrows(IllegalArgumentException::class.java) {
runBlocking {
DocumentTransactions().createEncryptedDocument(
walletHandle = 0L,
mnemonicResolverHandle = 0L,
ownerId = id32,
contractId = id32,
documentType = "txMetadata",
version = 1,
payload = payload,
signerHandle = 0L,
encryptionKeyIndex = -5,
)
}
}
assertTrue(
"message should name encryptionKeyIndex, got: ${e.message}",
e.message!!.contains("encryptionKeyIndex"),
)
}

/**
* The no-index path (`encryptionKeyIndex` omitted → `null`, the default and
* preferred generate-in-Rust route) must PASS the argument guards. With all
* other inputs valid, the only failure that can surface is the native call
* itself (no JNI library in a JVM unit test), NOT an
* [IllegalArgumentException] from our `require`s — proving `null` is a valid
* argument rather than a rejected one.
*/
@Test
fun noIndexPathPassesValidation() {
val t = runCatching {
runBlocking {
DocumentTransactions().createEncryptedDocument(
walletHandle = 0L,
mnemonicResolverHandle = 0L,
ownerId = id32,
contractId = id32,
documentType = "txMetadata",
version = 1,
payload = payload,
signerHandle = 0L,
// encryptionKeyIndex omitted → null → generate in Rust.
)
}
}.exceptionOrNull()
assertFalse(
"the null-index path must not be rejected as an argument error, got: $t",
t is IllegalArgumentException,
)
}
}
4 changes: 4 additions & 0 deletions packages/rs-platform-wallet-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ anyhow = { version = "1.0.81" }
# Swift decodes via Codable. See `tokens/group_queries.rs`.
serde_json = "1.0"
bs58 = "0.5"
# Base64-encode the decrypted (opaque) txMetadata payload in the fetch JSON,
# matching the codebase's binary-in-JSON convention. See `document.rs`
# `platform_wallet_fetch_encrypted_documents`.
base64 = "0.22.1"

# Zeroize intermediate key material crossing the FFI boundary.
zeroize = { version = "1", features = ["derive"] }
Expand Down
Loading
Loading