Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b5e7a03
feat(kotlin-sdk): wire-compatible encrypted txMetadata document creat…
bfoss765 Jul 10, 2026
dbfe0d4
test(platform-wallet): pin txMetadata wire-compat to a dashj-generate…
bfoss765 Jul 11, 2026
7354394
test(platform-wallet): pin encrypted-txMetadata FETCH to the real tes…
bfoss765 Jul 11, 2026
dbabb6a
debug(platform-wallet): warn-level stage breadcrumbs on the encrypted…
bfoss765 Jul 11, 2026
096bbaa
fix(platform-wallet): dual-emit encrypted-document breadcrumbs throug…
bfoss765 Jul 11, 2026
a637b1d
fix(platform-wallet): derive txMetadata keys through the mnemonic res…
bfoss765 Jul 11, 2026
3e86e17
fix(platform-wallet): nonzero-identity wire vector; keep master xprv …
bfoss765 Jul 11, 2026
2c2ee44
perf(platform-wallet): share the fetched DataContract Arc instead of …
bfoss765 Jul 12, 2026
13f131d
fix(kotlin-sdk): reject txMetadata version bytes the legacy stack can…
bfoss765 Jul 12, 2026
37294c7
fix(platform-wallet): enforce txMetadata wire version (0|1) in Rust c…
bfoss765 Jul 12, 2026
dfaa756
docs(txmetadata): scope legacy wire-compat to identity_index=0; relab…
bfoss765 Jul 12, 2026
af870ae
test(txmetadata): check in a real-DerivationPathFactory provenance ve…
bfoss765 Jul 12, 2026
3b391ea
fix(kotlin-sdk): gate encrypted-document create/fetch through the Tea…
bfoss765 Jul 16, 2026
51f8d22
fix(platform-wallet): pre-check txMetadata payload size before deriva…
bfoss765 Jul 21, 2026
f1d0098
fix(platform-wallet-ffi): zeroize + drop txMetadata plaintext before …
bfoss765 Jul 21, 2026
44ed158
docs(txmetadata): mark the fetch regression test as a manual/testnet-…
bfoss765 Jul 21, 2026
1496659
chore(txmetadata): strip internal tracker tokens from comments
bfoss765 Jul 21, 2026
44ffd1d
test(platform-wallet): anchor ENCRYPTED_METADATA_FIELD_MAX to the con…
bfoss765 Jul 21, 2026
50190a9
test(txmetadata): pin an independent legacy-INSTALL wire-compat vector
bfoss765 Jul 21, 2026
e76aaff
fix(kotlin-sdk): zeroize + early-drop the JNI txMetadata plaintext copy
bfoss765 Jul 22, 2026
32a31af
style: cargo fmt (#4186)
bfoss765 Jul 23, 2026
bf88c92
fix(sdk): harden encrypted txMetadata foundation
shumkov Aug 1, 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,111 @@ 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: 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`) and supplies its own per-document
* [encryptionKeyIndex] (dash-wallet's `1 + countAllRequests()` counter).
* 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.
*
* @param encryptionKeyIndex per-document index; non-negative.
* @param version payload version byte. Which values are meaningful is
* decided by the wallet core; an unsupported one is rejected there and
* surfaced as a platform-wallet invalid-parameter error.
* @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,
encryptionKeyIndex: Int,
version: Int,
payload: ByteArray,
signerHandle: Long,
): String = gate.op {
require(ownerId.size == 32) { "ownerId must be 32 bytes" }
require(contractId.size == 32) { "contractId must be 32 bytes" }
require(encryptionKeyIndex >= 0) {
"encryptionKeyIndex must be non-negative, got $encryptionKeyIndex"
}
mapNativeErrors {
TransactionsNative.documentCreateEncrypted(
walletHandle,
mnemonicResolverHandle,
ownerId,
contractId,
documentType,
encryptionKeyIndex,
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: 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 @@ -63,10 +63,11 @@ sealed class DashSdkError(
* rs-sdk-ffi `DashSDKErrorCode` range decoded above.
*
* The Android analog of Swift's `PlatformWalletError` enum
* (`PlatformWalletResult.swift`). Only the retry-semantics-bearing codes
* get dedicated types; everything else falls through to the
* [PlatformWallet] catch-all which still carries the native code + Rust
* message.
* (`PlatformWalletResult.swift`). Selected codes get dedicated types —
* those a caller is expected to branch on, such as ones carrying retry
* semantics or naming a specific recoverable condition; everything else
* falls through to the [PlatformWallet] catch-all which still carries the
* native code + Rust message.
*/
sealed class PlatformWallet(
message: String,
Expand All @@ -77,6 +78,20 @@ sealed class DashSdkError(
class InvalidHandle(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorInvalidParameter` (native code 2). The wallet core rejected a
* caller-supplied argument.
*
* The core owns which values are acceptable and explains the rejection
* in [message]; surface that text rather than restating the rule here,
* so the SDK cannot drift out of step with what the core enforces.
*
* Extends [Generic] so callers that already match the fallback on
* native code 2 keep working unchanged.
*/
class InvalidParameter(message: String, cause: Throwable? = null) :
Generic(nativeCode = 2, message = message, cause = cause)

/**
* `ErrorWalletOperation` (native code 6). A generic wallet-operation
* failure — the platform-wallet catch-all mapping, distinct from the
Expand Down Expand Up @@ -177,11 +192,15 @@ sealed class DashSdkError(
)

/**
* Any other `PlatformWalletFFIResultCode` without a dedicated type.
* Carries the platform-wallet [nativeCode] (already de-offset) and
* the Rust-supplied message.
* Any `PlatformWalletFFIResultCode` without a narrower type. Carries
* the platform-wallet [nativeCode] (already de-offset) and the
* Rust-supplied message, so callers can branch on the code directly.
*
* Open because narrower types extend it rather than replacing it: a
* code that gains its own type must keep satisfying the callers already
* matching this one, and must keep reporting the same [nativeCode].
*/
class Generic(
open class Generic(
val nativeCode: Int,
message: String,
cause: Throwable? = null,
Expand Down Expand Up @@ -232,6 +251,7 @@ sealed class DashSdkError(
): DashSdkError = when (code) {
// PlatformWalletFFIResultCode variants (platform-wallet-ffi/src/error.rs)
1 -> PlatformWallet.InvalidHandle(message, cause) // ErrorInvalidHandle
2 -> PlatformWallet.InvalidParameter(message, cause) // ErrorInvalidParameter
6 -> PlatformWallet.WalletOperation(message, cause) // ErrorWalletOperation
7, // ErrorIdentityNotFound
8, // ErrorContactNotFound
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,67 @@ 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 app's per-document index (dash-wallet's
* monotonic `1 + countAllRequests()` counter); non-negative.
* @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
Expand Up @@ -46,6 +46,79 @@ class DashSdkErrorTest {
assertFalse(DashSdkError.InvalidParameter("x").isRetryable)
}

/**
* A caller-input rejection decided by the Rust core must arrive as a typed
* platform-wallet invalid-parameter error carrying the core's own wording.
*
* The Rust core owns which values are acceptable. For the host to stop
* re-stating that policy it needs the rejection to be distinguishable from
* an unrelated failure and to keep its explanation intact, so the message
* can be surfaced without the host knowing what was wrong with the request.
*
* The message here is deliberately opaque: reproducing the core's actual
* wording would make this test a second copy of the very policy the host is
* supposed to be free of. The only contract this test owns is the numeric
* one — the offset code — plus the requirement that whatever text arrives
* is passed through untouched.
*/
@Test
fun platformWalletInvalidParameterIsTypedAndPreservesTheCoreMessage() {
val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET
val coreMessage = "rust-owned txMetadata validation detail"

val mapped = runCatching {
mapNativeErrors<Unit> { throw DashSDKException(offset + 2, coreMessage) }
}.exceptionOrNull()

assertTrue(
"a platform-wallet invalid-parameter code must map to its own type, " +
"not to the untyped fallback",
mapped is DashSdkError.PlatformWallet.InvalidParameter,
)
assertEquals(
"the core's explanation must reach the host unchanged",
coreMessage,
(mapped as DashSdkError).message,
)
}

/**
* Giving a code its own type must not strand callers that already branch on
* the untyped fallback.
*
* Existing code catches [DashSdkError.PlatformWallet.Generic] and inspects
* its native code to recognise specific failures. Introducing a narrower
* type for a code those callers already handle would silently stop matching
* for them, so the narrower type has to remain a Generic and keep reporting
* the same native code.
*/
@Test
fun platformWalletInvalidParameterRemainsCompatibleWithTheGenericFallback() {
val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET
val coreMessage = "rust-owned txMetadata validation detail"

val mapped = DashSdkError.fromNative(DashSDKException(offset + 2, coreMessage))

assertTrue(
"the narrower type must still satisfy the fallback callers match on",
mapped is DashSdkError.PlatformWallet.Generic,
)
assertTrue(
"and must still be the narrower type",
mapped is DashSdkError.PlatformWallet.InvalidParameter,
)
assertEquals(
"callers reading the native code off the fallback must still see it",
2,
(mapped as DashSdkError.PlatformWallet.Generic).nativeCode,
)
assertEquals(
"the core's explanation must reach the host unchanged",
coreMessage,
mapped.message,
)
}

@Test
fun platformWalletCodesMapToPlatformWalletSubtree() {
val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET
Expand Down
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