Skip to content

Commit 645bb47

Browse files
bfoss765claude
andcommitted
feat(sdk-ffi): structured SigningKeyUnavailable discriminator across the signer wire
Replace end-to-end message sniffing for the signer's "missing key" failure with a typed discriminator (dashpay#4060 finding 7): - rs-sdk-ffi: DashSDKSignerErrorCode { Generic = 0, SigningKeyUnavailable = 1, AuthenticationFailed = 2 (reserved) }; SignCompletionCallback and dash_sdk_sign_async_completion gain error_code: i32 (before error_message). SignResult stays Result<Vec<u8>, ProtocolError> (a new rs-dpp ProtocolError variant would carry serialization blast radius), so code 1 rides the single Rust-owned machine prefix DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX through ProtocolError::Generic — typed at both ABI edges, one constant bridging the string segment. This is an internal coordinated ABI change: every piece versions together in this monorepo. - rs-platform-wallet-ffi: PlatformWalletFFIResultCode:: ErrorSigningKeyUnavailable = 31 (codes 26-28 are reserved for dashpay#4185's reservation-token errors and 29/30 for dashpay#4184's asset-lock errors on sibling branches — documented in the enum as dashpay#4184 does). The From<dpp::ProtocolError> conversion restores the typed code from the prefix FIRST (before the loose keyword sniffs), and the From<PlatformWalletError> blanket impl restores it on the catch-all only (dedicated retry-semantics codes are never overridden) — covering the Sdk(dash_sdk::Error::Protocol(..)) wrapping path. - JNI/Kotlin: SignerNative.completeSign(token, signature, errorCode, errorMessage); KeystoreSigner passes SIGNER_ERROR_CODE_KEY_UNAVAILABLE on the null-key branch (keeping the MESSAGE_MARKER text for the transition window) and Generic everywhere else. DashSdkError maps 31 → PlatformWallet.SigningKeyUnavailable; the dashpay#4191 marker sniff on the catch-all codes remains as a deprecated old-native fallback with a removal note tied to the next minor release. - Swift: KeychainSigner trampolines forward the code (missing-row / missing-scalar outcomes classify as 1); PlatformWalletResultCode gains errorSigningKeyUnavailable = 31 → PlatformWalletError .signingKeyUnavailable (Kotlin parity). - Tests: rs-sdk-ffi completion-code tests (prefix present for code 1, absent for generic), platform-wallet-ffi prefix→31 tests on both conversion points, Kotlin code-31 + fallback-marker tests, Swift mapping and trampoline-classifier tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f66be6a commit 645bb47

21 files changed

Lines changed: 446 additions & 52 deletions

File tree

packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,13 @@ sealed class DashSdkError(
182182
* is missing (never derived, wiped, or written under a different
183183
* Keystore alias/policy) rather than the operation itself failing.
184184
*
185-
* Signing failures originate as free text in
186-
* `KeystoreSigner.completeSign` (the "[MESSAGE_MARKER] <pubkeyHex>"
187-
* string), travel through Rust, and come back under the catch-all
188-
* platform-wallet codes; [fromPlatformWalletNative] recognizes the
189-
* marker on the Kotlin boundary and surfaces this typed error so
190-
* hosts can route users to key repair (e.g.
185+
* Primary path (dashpay/platform#4060 finding 7): `KeystoreSigner`
186+
* completes with the STRUCTURED
187+
* `SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE`, which travels
188+
* typed across the Rust boundary and returns as
189+
* `PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable` (31) —
190+
* mapped directly here, no message inspection involved. Hosts route
191+
* users to key repair (e.g.
191192
* `PlatformWalletManager.repairIdentityKey`) instead of treating it
192193
* as an opaque [Generic] failure. Not retryable as-is — the key must
193194
* be (re-)derived first.
@@ -197,9 +198,17 @@ sealed class DashSdkError(
197198
companion object {
198199
/**
199200
* Stable prefix of the `KeystoreSigner` "missing key"
200-
* completion error. `KeystoreSigner` builds its message from
201-
* this constant, so the emitter and the matcher cannot
202-
* drift.
201+
* completion error message. `KeystoreSigner` builds its
202+
* message from this constant, so the emitter and the matcher
203+
* cannot drift.
204+
*
205+
* DEPRECATED as a discriminator: superseded by the typed
206+
* code-31 mapping above; the marker match is retained only
207+
* for the transition window where an old native library
208+
* (pre-code-31) is paired with new Kotlin during partial
209+
* builds. Remove the fallback (and this constant's matcher
210+
* role) in the next minor release once the native artifacts
211+
* are guaranteed current.
203212
*/
204213
const val MESSAGE_MARKER = "no private key stored for"
205214
}
@@ -280,11 +289,13 @@ sealed class DashSdkError(
280289
* [PlatformWallet] subtree — mirror of Swift's
281290
* `PlatformWalletError(result:)` construction. Retry-semantics-bearing
282291
* codes get dedicated types; the rest fall through to
283-
* [PlatformWallet.Generic] — except the `KeystoreSigner` "missing
284-
* key" completion error, which travels as free text through Rust and
285-
* is recognized by its [PlatformWallet.SigningKeyUnavailable]
286-
* message marker here (only on the catch-all codes, so the dedicated
287-
* retry-semantics types are never overridden).
292+
* [PlatformWallet.Generic]. The `KeystoreSigner` "missing key"
293+
* completion arrives TYPED as code 31
294+
* (`ErrorSigningKeyUnavailable`, dashpay/platform#4060 finding 7);
295+
* the legacy message-marker sniff on the catch-all codes is a
296+
* deprecated transition fallback for old native libraries (never
297+
* applied to the dedicated retry-semantics types, so those are
298+
* never overridden).
288299
*/
289300
private fun fromPlatformWalletNative(
290301
code: Int,
@@ -294,6 +305,10 @@ sealed class DashSdkError(
294305
// PlatformWalletFFIResultCode variants (platform-wallet-ffi/src/error.rs)
295306
1 -> PlatformWallet.InvalidHandle(message, cause) // ErrorInvalidHandle
296307
6 -> // ErrorWalletOperation
308+
// @Deprecated fallback: the marker sniff survives only for
309+
// old-native/new-Kotlin partial builds; the typed code 31
310+
// below is the real discriminator (#4060 finding 7). Remove
311+
// with MESSAGE_MARKER's matcher role next minor release.
297312
if (isSigningKeyUnavailable(message)) {
298313
PlatformWallet.SigningKeyUnavailable(message, cause)
299314
} else {
@@ -321,7 +336,15 @@ sealed class DashSdkError(
321336
23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked
322337
24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed
323338
25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch
339+
// ErrorSigningKeyUnavailable — the STRUCTURED signer
340+
// discriminator (dashpay/platform#4060 finding 7): the typed
341+
// completion code rides the whole Rust round-trip, no message
342+
// sniffing involved. (Codes 26-30 are reserved by sibling PRs
343+
// #4185 / #4184 — see PlatformWalletFFIResultCode.)
344+
31 -> PlatformWallet.SigningKeyUnavailable(message, cause)
324345
else ->
346+
// @Deprecated fallback — see the code-6 arm; code 31 is the
347+
// real discriminator.
325348
if (isSigningKeyUnavailable(message)) {
326349
PlatformWallet.SigningKeyUnavailable(message, cause)
327350
} else {

packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ package org.dashfoundation.dashsdk.ffi
66
*/
77
internal object SignerNative {
88

9+
/**
10+
* `DashSDKSignerErrorCode::Generic` — unclassified signing failure
11+
* (the historical behavior). Mirrors `rs-sdk-ffi/src/signer.rs`.
12+
*/
13+
const val SIGNER_ERROR_CODE_GENERIC = 0
14+
15+
/**
16+
* `DashSDKSignerErrorCode::SigningKeyUnavailable` — the signer has no
17+
* usable private key for the requested public key; the operation itself
18+
* did not fail. Travels typed across the completion ABI and comes back
19+
* as `PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable` (31) →
20+
* `DashSdkError.PlatformWallet.SigningKeyUnavailable`
21+
* (dashpay/platform#4060 finding 7). Mirrors `rs-sdk-ffi/src/signer.rs`.
22+
*/
23+
const val SIGNER_ERROR_CODE_KEY_UNAVAILABLE = 1
24+
925
/** Create a native `SignerHandle` backed by [bridge] (held as GlobalRef). */
1026
external fun createSigner(bridge: NativeSignerBridge): Long
1127

@@ -14,9 +30,16 @@ internal object SignerNative {
1430

1531
/**
1632
* Complete an in-flight sign request. Exactly once per token; pass
17-
* either a signature or an error message.
33+
* either a signature or an error message. [errorCode] is a
34+
* `DashSDKSignerErrorCode` discriminant classifying a failure
35+
* ([SIGNER_ERROR_CODE_GENERIC] when unclassified; ignored on success).
1836
*/
19-
external fun completeSign(token: Long, signature: ByteArray?, errorMessage: String?)
37+
external fun completeSign(
38+
token: Long,
39+
signature: ByteArray?,
40+
errorCode: Int,
41+
errorMessage: String?,
42+
)
2043

2144
/**
2245
* One-shot ECDSA sign: raw 32-byte private key + payload → signature,

packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class KeystoreSigner(
8484
SignerNative.completeSign(
8585
completionToken,
8686
null,
87+
SignerNative.SIGNER_ERROR_CODE_GENERIC,
8788
e.message ?: "signing failed",
8889
)
8990
}
@@ -101,23 +102,37 @@ class KeystoreSigner(
101102
val storageKey = storageKeyFor(pubkeyBytes)
102103
key = retrieveKeyWithAuth(storageKey)
103104
if (key == null) {
104-
// Built from the shared marker so the error survives the
105-
// Rust round-trip and comes back typed as
106-
// DashSdkError.PlatformWallet.SigningKeyUnavailable (the
107-
// fromPlatformWalletNative message match) instead of Generic.
105+
// The typed SIGNER_ERROR_CODE_KEY_UNAVAILABLE rides the
106+
// completion ABI and comes back as platform-wallet code 31 →
107+
// DashSdkError.PlatformWallet.SigningKeyUnavailable
108+
// (dashpay/platform#4060 finding 7). The MESSAGE_MARKER text
109+
// is ALSO kept during the transition window so an old native
110+
// library paired with new Kotlin (partial builds) still maps
111+
// via the deprecated message fallback.
108112
SignerNative.completeSign(
109113
completionToken,
110114
null,
115+
SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE,
111116
"${DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER} " +
112117
"${storageKey.take(16)}",
113118
)
114119
return
115120
}
116121
val signature = SignerNative.signWithPrivateKey(key, network.ffiValue, data)
117122
if (signature != null) {
118-
SignerNative.completeSign(completionToken, signature, null)
123+
SignerNative.completeSign(
124+
completionToken,
125+
signature,
126+
SignerNative.SIGNER_ERROR_CODE_GENERIC,
127+
null,
128+
)
119129
} else {
120-
SignerNative.completeSign(completionToken, null, "signing returned no data")
130+
SignerNative.completeSign(
131+
completionToken,
132+
null,
133+
SignerNative.SIGNER_ERROR_CODE_GENERIC,
134+
"signing returned no data",
135+
)
121136
}
122137
} finally {
123138
key?.fill(0)
@@ -146,6 +161,7 @@ class KeystoreSigner(
146161
SignerNative.completeSign(
147162
completionToken,
148163
null,
164+
SignerNative.SIGNER_ERROR_CODE_GENERIC,
149165
"no platform address row for $hashHex",
150166
)
151167
return
@@ -157,6 +173,7 @@ class KeystoreSigner(
157173
SignerNative.completeSign(
158174
completionToken,
159175
null,
176+
SignerNative.SIGNER_ERROR_CODE_GENERIC,
160177
"no signable platform address row for $hashHex " +
161178
"(no candidate has both a derivation path and a stored mnemonic)",
162179
)
@@ -170,6 +187,7 @@ class KeystoreSigner(
170187
SignerNative.completeSign(
171188
completionToken,
172189
null,
190+
SignerNative.SIGNER_ERROR_CODE_GENERIC,
173191
"no mnemonic stored for wallet of platform address $hashHex",
174192
)
175193
return
@@ -183,9 +201,19 @@ class KeystoreSigner(
183201
SignerNative.signWithMnemonicAndPathInto(m, path, net, payload)
184202
}
185203
if (signature != null) {
186-
SignerNative.completeSign(completionToken, signature, null)
204+
SignerNative.completeSign(
205+
completionToken,
206+
signature,
207+
SignerNative.SIGNER_ERROR_CODE_GENERIC,
208+
null,
209+
)
187210
} else {
188-
SignerNative.completeSign(completionToken, null, "signing returned no data")
211+
SignerNative.completeSign(
212+
completionToken,
213+
null,
214+
SignerNative.SIGNER_ERROR_CODE_GENERIC,
215+
"signing returned no data",
216+
)
189217
}
190218
}
191219

packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,29 @@ class DashSdkErrorTest {
128128
assertFalse("Generic platform-wallet errors are not retryable", mapped.isRetryable)
129129
}
130130

131+
@Test
132+
fun signingKeyUnavailableCode31MapsTyped() {
133+
// The STRUCTURED discriminator (dashpay/platform#4060 finding 7):
134+
// PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable (31) maps
135+
// to the typed error on the code alone — no message inspection.
136+
val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET
137+
val mapped = DashSdkError.fromNative(
138+
DashSDKException(offset + 31, "arbitrary human text, no marker"),
139+
)
140+
assertTrue(mapped is DashSdkError.PlatformWallet.SigningKeyUnavailable)
141+
assertFalse(mapped.isRetryable)
142+
}
143+
131144
@Test
132145
fun signingKeyUnavailableIsRecognizedByItsMessageMarker() {
133146
val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET
134147
val marker = DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER
135-
// The KeystoreSigner completion error travels as free text through
136-
// Rust and returns under the catch-all codes (ErrorUnknown = 99 via
137-
// the blanket PlatformWalletError conversion, sometimes wrapped as
138-
// ErrorWalletOperation = 6) — both must surface typed (#4052).
148+
// DEPRECATED transition fallback: an OLD native library (pre the
149+
// typed code 31) still returns the completion error as free text
150+
// under the catch-all codes (ErrorUnknown = 99 via the blanket
151+
// PlatformWalletError conversion, sometimes wrapped as
152+
// ErrorWalletOperation = 6) — both must keep surfacing typed until
153+
// the fallback's removal (#4052, #4060 finding 7).
139154
for (code in intArrayOf(6, 99)) {
140155
val mapped = DashSdkError.fromNative(
141156
DashSDKException(offset + code, "Signing failed: $marker deadbeef00112233…"),

packages/rs-platform-wallet-ffi/src/error.rs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,27 @@ pub enum PlatformWalletFFIResultCode {
168168
/// Existing-lock recovery attempted to use a lock for the wrong funding
169169
/// family or bound identity index.
170170
ErrorAssetLockFundingMismatch = 25,
171+
// Codes 26-28 are reserved: the deferred-payment reservation-token errors
172+
// (ErrorStaleReservationToken / ErrorReservationTokenConsumed /
173+
// ErrorReservationWalletMismatch) claim them on the split-build-broadcast
174+
// branch (dashpay/platform#4185), and 29/30 belong to the asset-lock
175+
// funding errors (ErrorAssetLockInsufficientFunds /
176+
// ErrorAssetLockCrossDomainConsentRequired) on the multi-account branch
177+
// (dashpay/platform#4184); allocating any of them here too would merge
178+
// without textual conflict and silently misclassify across hosts.
179+
/// A state transition could not be signed because the signer has no
180+
/// usable private key for the requested public key — the stored blob is
181+
/// missing, stranded, or written under a different Keystore/Keychain
182+
/// alias — rather than the operation itself failing. Restored from the
183+
/// typed signer completion code
184+
/// ([`rs_sdk_ffi::DashSDKSignerErrorCode::SigningKeyUnavailable`]) via
185+
/// the stable machine prefix
186+
/// [`rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX`] riding the
187+
/// `ProtocolError::Generic` segment (dashpay/platform#4060 finding 7).
188+
/// Hosts route this to key repair instead of treating it as an opaque
189+
/// wallet-operation failure. Not retryable as-is — the key must be
190+
/// (re-)derived first.
191+
ErrorSigningKeyUnavailable = 31,
171192

172193
NotFound = 98, // Used exclusively for all the Option that are retuned as errors
173194
ErrorUnknown = 99,
@@ -329,6 +350,19 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
329350
PlatformWalletError::AssetLockFundingMismatch { .. } => {
330351
PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch
331352
}
353+
// A signer failure can also reach this blanket impl wrapped as
354+
// `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(..))` (any
355+
// wallet operation that propagates the SDK error via `?`). The
356+
// typed discriminator rides the stable machine prefix in the
357+
// rendered message — restore it here too, but ONLY on the
358+
// catch-all: the dedicated retry-semantics codes above are never
359+
// overridden (dashpay/platform#4060 finding 7).
360+
_ if error
361+
.to_string()
362+
.contains(rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX) =>
363+
{
364+
PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable
365+
}
332366
_ => PlatformWalletFFIResultCode::ErrorUnknown,
333367
};
334368
PlatformWalletFFIResult::err(code, error.to_string())
@@ -421,7 +455,13 @@ impl From<bincode::error::DecodeError> for PlatformWalletFFIResult {
421455
impl From<dpp::ProtocolError> for PlatformWalletFFIResult {
422456
fn from(e: dpp::ProtocolError) -> Self {
423457
let msg = e.to_string();
424-
let code = if msg.contains("identifier") {
458+
// The signer's typed SigningKeyUnavailable completion rides the
459+
// stable machine prefix through ProtocolError::Generic
460+
// (dashpay/platform#4060 finding 7) — restore the typed code FIRST,
461+
// before any of the loose keyword sniffs below can misroute it.
462+
let code = if msg.contains(rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX) {
463+
PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable
464+
} else if msg.contains("identifier") {
425465
PlatformWalletFFIResultCode::ErrorInvalidIdentifier
426466
} else if msg.contains("deserialization") || msg.contains("decode") {
427467
PlatformWalletFFIResultCode::ErrorDeserialization
@@ -810,4 +850,52 @@ mod tests {
810850
let result: PlatformWalletFFIResult = err.into();
811851
assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown);
812852
}
853+
854+
/// The typed SigningKeyUnavailable signer completion rides the stable
855+
/// machine prefix through `ProtocolError::Generic`; the conversion must
856+
/// restore code 31 (dashpay/platform#4060 finding 7) — and must do so
857+
/// BEFORE the loose keyword sniffs (the human message may well contain
858+
/// "identifier" or similar).
859+
#[test]
860+
fn signer_key_unavailable_prefix_maps_to_code_31() {
861+
let e = dpp::ProtocolError::Generic(format!(
862+
"{}no private key stored for identifier 02abcd",
863+
rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX
864+
));
865+
let result: PlatformWalletFFIResult = e.into();
866+
assert_eq!(
867+
result.code,
868+
PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable
869+
);
870+
}
871+
872+
/// The same prefix arriving wrapped in the SDK-error path (the blanket
873+
/// `From<PlatformWalletError>` catch-all) restores code 31 too — but
874+
/// only on the catch-all; typed variants keep their codes.
875+
#[test]
876+
fn signer_key_unavailable_prefix_maps_on_the_sdk_catch_all() {
877+
let err = PlatformWalletError::Sdk(dash_sdk::Error::Protocol(dpp::ProtocolError::Generic(
878+
format!(
879+
"{}no private key stored for 02abcd",
880+
rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX
881+
),
882+
)));
883+
let result: PlatformWalletFFIResult = err.into();
884+
assert_eq!(
885+
result.code,
886+
PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable
887+
);
888+
}
889+
890+
/// A generic protocol error without the prefix keeps the historical
891+
/// mapping — no message sniffing beyond the machine prefix.
892+
#[test]
893+
fn generic_protocol_error_without_prefix_is_unchanged() {
894+
let e = dpp::ProtocolError::Generic("no private key stored for 02abcd".to_string());
895+
let result: PlatformWalletFFIResult = e.into();
896+
assert_eq!(
897+
result.code,
898+
PlatformWalletFFIResultCode::ErrorWalletOperation
899+
);
900+
}
813901
}

0 commit comments

Comments
 (0)