Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ sealed class DashSdkError(
class AssetLockFundingMismatch(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorSigningKeyUnavailable` (native code 31). The wallet holds no
* usable signing key for the requested address, so the operation can
* never succeed as asked — retrying is pointless (hence no
* [isRetryable] override). Raised by
* [org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.signMessage]
* for an address this wallet does not own, or owns only watch-only (a
* DashPay *external* account holds a contact's addresses, whose private
* keys we never had).
*
* Gets a dedicated type rather than falling through to [Generic]
* because hosts act on it differently from a generic failure: it means
* "correct the address or repair the keys", not "try again".
*/
class SigningKeyUnavailable(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend
* could not be built against a Platform-recorded anchor because the
Expand Down Expand Up @@ -245,6 +262,7 @@ sealed class DashSdkError(
23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked
24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed
25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch
31 -> PlatformWallet.SigningKeyUnavailable(message, cause) // ErrorSigningKeyUnavailable
else -> PlatformWallet.Generic(code, message, cause)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,28 @@ internal object WalletManagerNative {
*/
external fun platformWalletGetCore(walletHandle: Long): Long

/**
* `core_wallet_sign_message` — sign [message] with the private key behind
* [address] and return the base64 signature (a classic Dash signed message).
*
* [coreHandle] is a core-wallet handle from [platformWalletGetCore].
* [address] must be a P2PKH address of THIS wallet on its network, owned by
* a signable funds account: a foreign or watch-only address throws
* `ErrorSigningKeyUnavailable` (31), while an unparseable, wrong-network, or
* non-P2PKH address throws `ErrorInvalidParameter` (2). [message] is signed
* verbatim — it is length-prefixed into the digest, so trailing whitespace
* and newlines are significant, and an empty string is valid and signable.
* [coreSignerHandle] is the manager's `MnemonicResolverHandle`.
*
* Moves no value: nothing is selected, reserved, broadcast, or persisted.
*/
external fun coreWalletSignMessage(
coreHandle: Long,
address: String,
message: String,
coreSignerHandle: Long,
): String

/**
* `core_wallet_broadcast_transaction` — broadcast a transaction built by
* [coreTxBuilderBuildSigned]. [accountType]/[accountIndex] identify the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable {
)
}

/**
* Sign [message] with the private key behind [address] and return the base64
* signature — a classic Dash signed message. See
* [ManagedPlatformWallet.signMessage] for the full contract; drive this
* through it, not directly.
*/
internal fun signMessage(
address: String,
message: String,
coreSignerHandle: Long,
): String =
WalletManagerNative.coreWalletSignMessage(
handle,
address,
message,
coreSignerHandle,
)

override fun close() {
cleanable.clean()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,122 @@ class ManagedPlatformWallet internal constructor(
}
}

/**
* Sign [message] with the private key behind [address] and return the
* signature as base64 — a **classic Dash signed message**, byte-for-byte
* compatible with dashj's `ECKey.signMessage` and Dash Core's `signmessage`
* RPC, and verifiable by `verifymessage`, `ECKey.verifyMessage`, and
* CrowdNode's server-side check.
*
* **The format.** The signed digest is
* `SHA256d(prefix ‖ varint(bytes.size) ‖ bytes)` over
* `bytes = message.toByteArray(Charsets.UTF_8)`: the length prefix counts
* **UTF-8 bytes**, not `String.length`, which counts UTF-16 code units and
* diverges for any non-ASCII text. The prefix is
* the historical `"\x19DarkCoin Signed Message:\n"` — *not* `"Dash"`. Dash
Comment on lines +182 to +187

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Document the signed message's UTF-8 byte length

signed_msg_hash prefixes msg.len(), which is the serialized UTF-8 byte count. Kotlin's message.length counts UTF-16 code units, so the documented formula is wrong for non-ASCII messages; for example, "é" has length 1 but contributes 2 UTF-8 bytes. State that the varint contains message.toByteArray(Charsets.UTF_8).size. The analogous Swift documentation at packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift:145 must use message.utf8.count instead of message.count, which counts extended grapheme clusters.

source: ['codex']

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4270d82 (docs only). The Kotlin doc now states the varint prefix is message.toByteArray(Charsets.UTF_8).size, and the Swift doc at ManagedCoreWallet.swift uses message.utf8.count — each with a note on why the language-native count (UTF-16 code units / grapheme clusters) diverges for non-ASCII input. The Rust module doc was already byte-accurate.

* inherited that string from before the rename and every existing verifier
* depends on it, so it can never change. The returned signature is the
* 65-byte BIP-137-style recoverable form (`header ‖ r ‖ s`, with
* `header = 27 + recoveryId + 4`, the `+ 4` marking a compressed public
* key), base64-encoded. A verifier recovers the public key from the digest
* and compares its hash to [address] — which is why only P2PKH addresses
* can sign: no other payload has a defined recovery comparison.
*
* **The CrowdNode use case.** This is the primitive the Android wallet's
* CrowdNode integration needs: it signs short strings — a withdrawal amount,
* the account email — which CrowdNode verifies against the address that
* funded the account. It is a **proof of address ownership, not a spend**:
* no UTXO is selected, reserved, spent, or broadcast, no balance changes, and
* nothing is persisted. Calling it repeatedly is free and side-effect-free.
*
* **Which addresses can sign.** [address] must be a P2PKH address of *this*
* wallet, on this wallet's network, already derived into one of its address
* pools, and belonging to a **signable funds account** (BIP44 / BIP32 /
* CoinJoin / DashPay-*receiving*). A watch-only DashPay **external** account
* holds a contact's receiving addresses whose private keys we never had, so
* those are refused exactly like any other address the wallet does not own:
* [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.SigningKeyUnavailable].
*
* An unparseable, wrong-network, or non-P2PKH address is caller input and
* surfaces natively as `ErrorInvalidParameter` (2), which has no dedicated
* Kotlin arm today and therefore arrives as
* [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.Generic]
* with `code == 2`; the message names which of the three it was. Branch on
* [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.SigningKeyUnavailable]
* to tell "wallet does not own this address" apart from "this is not a
* usable address".
*
* **Determinism.** Signing is RFC6979 deterministic and low-s normalized, so
* the same ([address], [message]) pair on the same seed always returns the
* same string. Two calls yielding different signatures means the key or the
* message differed.
*
* Runs through the manager's [TeardownGate] like every other native op.
*
* @param address the P2PKH address whose key signs; must be one this wallet
* owns and has derived.
* @param message the string to sign, **verbatim** as UTF-8. It is
* length-prefixed into the digest, so trailing whitespace and newlines are
* significant and the verifier must receive the identical bytes. An empty
* string is valid. Must be well-formed text: a string holding an unpaired
* UTF-16 surrogate has no UTF-8 encoding and is rejected with
* [IllegalArgumentException] rather than signed after a silent
* substitution.
* @param coreSignerHandle the manager's `MnemonicResolverHandle`
* (`PlatformWalletManager.mnemonicResolverHandle`); no private key crosses
* the boundary.
* @return the base64 signature (88 characters for the 65-byte payload).
*/
suspend fun signMessage(
address: String,
message: String,
coreSignerHandle: Long,
): String = gate.op {
require(address.isNotEmpty()) { "address must not be empty" }
// The digest commits to the message's UTF-8 bytes, but a Kotlin String
// is an unvalidated UTF-16 sequence and may hold an unpaired surrogate,
// which has no UTF-8 encoding at all. Every conversion below is LENIENT
// and they do not even agree: the JNI bridge's String read substitutes
// U+FFFD, while `toByteArray(Charsets.UTF_8)` substitutes '?'. Either
// way the wallet would sign bytes the caller never wrote and hand back a
// signature that verifies for a different message — silently. Rejected
// here, the one layer that still has the exact UTF-16 and can say why.
require(message.hasNoUnpairedSurrogate()) {
"message must be well-formed text: it contains an unpaired UTF-16 surrogate, " +
"which has no UTF-8 encoding and would be silently substituted before signing"
}

mapNativeErrors {
coreWallet().use { core ->
core.signMessage(address, message, coreSignerHandle)
}
}
}

/**
* Whether every UTF-16 surrogate in this string is part of a well-formed
* high/low pair — i.e. whether the string has an exact UTF-8 encoding.
*
* Scanned directly rather than via a strict `CharsetEncoder` to keep the
* check allocation-free on the hot path; the two agree on exactly which
* strings are encodable.
*/
private fun String.hasNoUnpairedSurrogate(): Boolean {
var i = 0
while (i < length) {
val c = this[i]
when {
c.isHighSurrogate() -> {
if (i + 1 >= length || !this[i + 1].isLowSurrogate()) return false
i += 2
}
c.isLowSurrogate() -> return false
else -> i++
}
}
return true
}

/**
* The wallet's Platform-payment addresses that currently hold credits,
* each as a [FundingInput] whose `credits` is the full cached balance —
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

mod addresses;
mod broadcast;
mod sign_message;
mod transaction_builder;
mod wallet;

pub use addresses::*;
pub use broadcast::*;
pub use sign_message::*;
pub use transaction_builder::*;
pub use wallet::*;
Loading
Loading