From 8f067da1259875355285e058ffb4add65425b591 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 6 Aug 2026 19:08:16 +0700 Subject: [PATCH] fix(platform-wallet): type signer-reported missing key as MessageSigningKeyUnavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A missing private key reported by the signer during classic message signing was wrapped into MessageSigningFailed with context text prepended before the reserved key-unavailable marker, so the FFI boundary could no longer recognize the condition and it flattened to ErrorUnknown instead of ErrorSigningKeyUnavailable (31). Close it with both halves of the marker contract, in-repo: - MnemonicResolverCoreSigner::NotFound (the production missing-key completion) now renders with DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX at position 0 — the one typed signal a Display-only Signer::Error can carry. - CoreWallet::sign_message checks that prefix on the signer's unwrapped rendering BEFORE composing the "signer rejected the digest at {path}" context, and returns the typed PlatformWalletError::MessageSigningKeyUnavailable, which the FFI already maps to code 31 without parsing any formatted reason. The check stays position-0 only; a mid-string mention of the marker still wraps as MessageSigningFailed (per the #4183 review rule). Flips signer_key_unavailable_is_not_preserved_during_message_signing — which pinned the old limitation — to signer_key_unavailable_is_typed_during_message_signing asserting the typed result, and adds producer (marker at position 0 of NotFound) and guard (mid-string marker not promoted) tests. Co-Authored-By: Claude Fable 5 --- .../src/core_wallet/sign_message.rs | 5 +- packages/rs-platform-wallet-ffi/src/error.rs | 126 ++++++--------- packages/rs-platform-wallet/src/error.rs | 46 ++++-- .../src/wallet/core/sign_message.rs | 146 +++++++++++------- .../src/mnemonic_resolver_core_signer.rs | 31 +++- 5 files changed, 204 insertions(+), 150 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/sign_message.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/sign_message.rs index 911ebe1dc51..6c39057fbf6 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/sign_message.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/sign_message.rs @@ -67,8 +67,9 @@ unsafe fn read_utf8( /// * `address_ptr`/`address_len` — the UTF-8 P2PKH address whose key signs. Must /// be one of this wallet's own addresses, on the wallet's network, and belong /// to a signable funds account (BIP44 / BIP32 / CoinJoin / -/// DashPay-receiving). A foreign address, or a watch-only DashPay *external* -/// account's address, fails with +/// DashPay-receiving). A foreign address, a watch-only DashPay *external* +/// account's address, or a resolver that holds no mnemonic for this wallet +/// (the signer's key-unavailable completion) fails with /// [`PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable`] (31); an /// unparseable, wrong-network, or non-P2PKH address fails with /// [`PlatformWalletFFIResultCode::ErrorInvalidParameter`] (2). diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 07d03a9765a..9ec44709de2 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -235,12 +235,17 @@ pub enum PlatformWalletFFIResultCode { /// wallet-operation failure. Not retryable as-is — the key must be /// (re-)derived first. /// - /// Also produced WITHOUT the signer round-trip, by - /// [`CoreWallet::sign_message`](platform_wallet::CoreWallet::sign_message): - /// a message-signing address that belongs to no signable funds account of - /// this wallet means no key can exist for it, which is the same conclusion - /// this code exists to carry — hosts route both to key repair / address - /// correction rather than to an opaque wallet-operation failure. + /// Also produced by + /// [`CoreWallet::sign_message`](platform_wallet::CoreWallet::sign_message), + /// both without a signer round-trip (a message-signing address that + /// belongs to no signable funds account of this wallet, so no key can + /// exist for it) and from the signer itself (the resolver reported no + /// mnemonic stored — `MnemonicResolverCoreSigner::NotFound`, whose + /// rendering stamps the same machine prefix at position 0 and which + /// `sign_message` promotes to the typed + /// `MessageSigningKeyUnavailable` before adding context). Hosts route + /// all of these to key repair / address correction rather than to an + /// opaque wallet-operation failure. ErrorSigningKeyUnavailable = 31, NotFound = 98, // Used exclusively for all the Option that are retuned as errors @@ -446,76 +451,35 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::MessageSigningMessageInvalid { .. } => { PlatformWalletFFIResultCode::ErrorInvalidParameter } - // A second, signer-free producer of code 31 (the arm above is the - // first): a message-signing address that belongs to no signable - // funds account means no key can exist for it — the same conclusion - // the code carries — so hosts route it to key repair / address - // correction instead of an opaque wallet-operation failure. + // A second producer of code 31 (the arm above is the first), + // reached without any marker inspection at this layer: message + // signing found no signable account for the address, or the signer + // reported its key missing (the reserved marker at position 0 of + // its rendering, which `sign_message` promotes to this typed + // variant BEFORE composing any context string). Either way no key + // can sign as things stand, so hosts route it to key repair / + // address correction instead of an opaque failure. PlatformWalletError::MessageSigningKeyUnavailable { .. } => { PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable } // NOTE: `MessageSigningFailed` is deliberately NOT matched, so it // falls to the `ErrorUnknown` catch-all below. Its causes are // internal invariant breaks (a public key that does not own the - // address, no recovery id that recovers it) which should read as a - // bug rather than as a key-repair prompt, and it carries the - // signer's own `Display`, which reaches the host in the message - // either way. + // address, no recovery id that recovers it) or an unclassifiable + // signer failure, which should read as a bug rather than as a + // key-repair prompt; it carries the signer's own `Display`, which + // reaches the host in the message either way. // - // It is NOT promoted to code 31 by the key-unavailable arm above, - // and that is deliberate rather than an oversight. That arm matches - // STRUCTURALLY — `Sdk(Protocol(Generic(s)))` with the marker at - // position 0 — because #4183's review rejected sniffing the marker - // as a substring of the rendered error: a foreign signer can merely - // mention the token in human-readable text. `MessageSigningFailed` - // is a different variant, and `sign_message` composes its `reason` - // as "signer rejected the digest at {path}: {e}", so the marker - // could only ever appear mid-string. Matching it here would mean - // exactly the substring sniff that review ruled out. - // - // Consequence worth knowing: a signer-reported key-unavailable - // condition reaching `sign_message` surfaces as ErrorUnknown, not - // 31. That is NOT closable at this layer, and — having chased it — - // not closable at the producer either without an upstream change. - // The type chain is the whole story: - // - // * `preserve_signer_key_unavailable_or` (platform-wallet's own - // helper, #4183) takes a `dash_sdk::Error` and matches - // `Protocol(Generic(s))` with the marker at position 0. It is - // the right tool — for the STATE-TRANSITION signing paths - // (document replace, DPNS, token transfer), whose failures ARE - // `dash_sdk::Error`, which is where it is used. - // * Message signing does not use that surface at all. It calls - // key-wallet's `Signer::sign_ecdsa`, whose error is the - // associated type `S::Error`, bounded only by - // `Display + Send + Sync + 'static`. There is no enum to match: - // no `dash_sdk::Error`, no `ProtocolError`, nothing structural. - // * The one production impl, `MnemonicResolverCoreSigner` - // (rs-sdk-ffi), has `Error = MnemonicResolverSignerError` — a - // typed enum that never stamps the marker. Its `NotFound` - // ("mnemonic not found in keychain") IS the key-unavailable - // case, but nothing distinguishes it once it is `Display`ed. - // * The marker is produced only in `rs-sdk-ffi`'s state-transition - // completion callback (`SignResult = Result, - // ProtocolError>`), never on a `Signer::sign_ecdsa` path. - // - // So a position-0 check on the signer's rendering would have zero - // producers today, and a `contains` check is the substring sniff - // #4183's review rejected. Note the marker constant IS visible here - // now (#4183 mirrored it as - // `platform_wallet::error::SIGNER_KEY_UNAVAILABLE_PREFIX`, pinned - // byte-identical by a compile-time assertion in this crate) — the - // blocker is the error TYPE, not the constant, which corrects an - // earlier note in this file's history. - // - // The fix belongs upstream, in ONE of: - // (a) `MnemonicResolverCoreSigner` rendering its key-unavailable - // variants with the marker at position 0, after which a - // position-0 check in `sign_message` becomes principled; or - // (b) key-wallet tightening `Signer::Error` so callers can match - // a typed key-unavailable variant instead of a string. - // Both change shared, externally-consumed surfaces and want their - // own review; neither is in scope for message signing. + // The one signer failure with typed meaning never lands here: + // key-wallet's `Signer::Error` is bounded only by `Display`, so a + // key-unavailable backend stamps the reserved marker at position 0 + // of its rendering (`MnemonicResolverCoreSigner::NotFound`), and + // `sign_message` recognizes exactly that — a position-0 check on + // the UNWRAPPED rendering, never a substring sniff of a composed + // reason (#4183 review) — and returns the typed + // `MessageSigningKeyUnavailable` mapped above. By the time a + // `MessageSigningFailed` reason exists, any marker in it sits + // mid-string and is deliberately not matched. _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -1068,10 +1032,12 @@ mod tests { ); } - /// A second producer of code 31, reached with no signer round-trip and no - /// marker sniffing at all: the wallet simply holds no key for the address. - /// Hosts branch on it to correct the address or repair the key, so it must - /// not flatten to ErrorUnknown. + /// A second producer of code 31, reached with no marker inspection at + /// this layer: message signing concluded no key can sign for the address + /// (no signable account owns it, or the signer reported the key missing + /// and `sign_message` promoted the position-0 marker to this typed + /// variant). Hosts branch on it to correct the address or repair the key, + /// so it must not flatten to ErrorUnknown. #[test] fn message_signing_key_unavailable_maps_to_code_31() { let err = PlatformWalletError::MessageSigningKeyUnavailable { @@ -1180,12 +1146,14 @@ mod tests { /// `MessageSigningMessageInvalid` mapping to ErrorInvalidParameter. What /// remains here is genuinely internal. /// - /// #4183's key-unavailable promotion does NOT reach this variant, by - /// design: it matches `Sdk(Protocol(Generic(s)))` structurally with the - /// marker at position 0, because that review rejected sniffing the marker as - /// a substring. `sign_message` composes `reason` as - /// "signer rejected the digest at {path}: {e}", so a marker could only ever - /// sit mid-string here. See the NOTE on the mapping arm. + /// A signer-reported key-unavailable failure never lands on this variant: + /// `sign_message` checks the reserved marker at position 0 of the signer's + /// UNWRAPPED rendering and returns the typed + /// `MessageSigningKeyUnavailable` before composing `reason` as + /// "signer rejected the digest at {path}: {e}". Once a reason exists, any + /// marker in it sits mid-string, and matching it there would be the + /// substring sniff #4183's review rejected. See the NOTE on the mapping + /// arm. #[test] fn message_signing_failed_falls_through_to_unknown() { let internal = PlatformWalletError::MessageSigningFailed { diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 3e3632b7272..4af28baf1a0 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -120,15 +120,23 @@ pub enum PlatformWalletError { #[error("the message to sign for address {address} is not valid UTF-8: {reason}")] MessageSigningMessageInvalid { address: String, reason: String }, - /// [`CoreWallet::sign_message`] was given a well-formed P2PKH address for - /// the right network that this wallet holds no signing key for: it belongs - /// to no *signable* funds account (BIP44 / BIP32 / CoinJoin / - /// DashPay-receiving), or it belongs to a watch-only DashPay **external** - /// account — a contact's receiving address, whose keys we never had. + /// [`CoreWallet::sign_message`] holds no usable signing key for a + /// well-formed P2PKH address on the right network. Two producers: /// - /// Distinct from a signer *failure*: nothing was attempted, because no - /// derivation path resolves the address. Carries no retry value as-is; the - /// caller must supply an address the wallet owns. + /// * **Address resolution** — the address belongs to no *signable* funds + /// account (BIP44 / BIP32 / CoinJoin / DashPay-receiving), or it belongs + /// to a watch-only DashPay **external** account (a contact's receiving + /// address, whose keys we never had). No signer is invoked. + /// * **The signer itself** — the backend reported its key missing, stamped + /// as the reserved [`SIGNER_KEY_UNAVAILABLE_PREFIX`] at position 0 of its + /// error rendering (`MnemonicResolverCoreSigner::NotFound` in + /// production: the keychain holds no mnemonic for the wallet). + /// `sign_message` checks that marker BEFORE prepending any context, so + /// the condition stays typed across the FFI. + /// + /// Either way the conclusion is the same — no key can sign for this + /// address as things stand — so hosts route both to key repair / address + /// correction (FFI code 31). Carries no retry value as-is. /// /// [`CoreWallet::sign_message`]: crate::wallet::core::CoreWallet::sign_message #[error( @@ -156,14 +164,14 @@ pub enum PlatformWalletError { /// the host mirror-enum churn that follows one. /// /// Deliberately NOT given a dedicated FFI code: [`Signer::Error`] is - /// generic and bounded only by `Display`, so it cannot be classified - /// structurally here. Signer failures — including a signer-reported - /// key-unavailable completion — remain `MessageSigningFailed` and fall - /// through to `ErrorUnknown`. Only [`MessageSigningKeyUnavailable`] - /// (address resolution failing before a signer is ever invoked) reaches - /// FFI code 31. See the `MessageSigningFailed` arm's NOTE in - /// `platform-wallet-ffi`'s error conversion for the full type chain and - /// the upstream change that would be needed to close this gap. + /// generic and bounded only by `Display`, so what lands here cannot be + /// classified structurally and falls through to `ErrorUnknown`. The one + /// signer failure with a typed meaning — a key-unavailable rendering with + /// [`SIGNER_KEY_UNAVAILABLE_PREFIX`] at position 0 — never reaches this + /// variant: `sign_message` promotes it to + /// [`MessageSigningKeyUnavailable`] (FFI code 31) before any context + /// string is composed. See the `MessageSigningFailed` arm's NOTE in + /// `platform-wallet-ffi`'s error conversion. /// /// [`MessageSigningKeyUnavailable`]: Self::MessageSigningKeyUnavailable /// [`Signer::Error`]: key_wallet::signer::Signer::Error @@ -604,6 +612,12 @@ pub fn promote_address_nonce_error_or_sdk(error: dash_sdk::Error) -> PlatformWal /// The reserved machine prefix that a typed `SigningKeyUnavailable` signer /// completion stamps at the **start** of its `ProtocolError::Generic` payload. +/// Also stamped at position 0 of `MnemonicResolverCoreSigner::NotFound`'s +/// `Display`, which is how a missing key stays recognizable across key-wallet's +/// `Signer` surface (whose error type is only `Display`) — `sign_message` +/// checks this prefix on the signer's rendering before adding any context and +/// promotes the failure to the typed +/// [`PlatformWalletError::MessageSigningKeyUnavailable`]. /// /// Canonically owned by the signer-completion boundary as /// [`rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX`]. It is mirrored diff --git a/packages/rs-platform-wallet/src/wallet/core/sign_message.rs b/packages/rs-platform-wallet/src/wallet/core/sign_message.rs index 1c781df1063..62301101d5c 100644 --- a/packages/rs-platform-wallet/src/wallet/core/sign_message.rs +++ b/packages/rs-platform-wallet/src/wallet/core/sign_message.rs @@ -54,7 +54,7 @@ use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::ManagedAccountType; use crate::broadcaster::TransactionBroadcaster; -use crate::error::PlatformWalletError; +use crate::error::{PlatformWalletError, SIGNER_KEY_UNAVAILABLE_PREFIX}; use crate::wallet::core::CoreWallet; /// Every recovery id a compact ECDSA signature can carry. Ids 2 and 3 encode an @@ -118,7 +118,12 @@ impl CoreWallet { /// [`SignerMethod::Digest`]; a `Transaction`-only backend cannot sign a /// message and is refused with /// [`MessageSigningFailed`](PlatformWalletError::MessageSigningFailed) - /// before it is invoked. + /// before it is invoked. A signer that reports its key missing — the + /// reserved key-unavailable marker at position 0 of its error rendering, + /// as `MnemonicResolverCoreSigner::NotFound` does when the keychain holds + /// no mnemonic — surfaces as + /// [`MessageSigningKeyUnavailable`](PlatformWalletError::MessageSigningKeyUnavailable), + /// the same typed condition as an unowned address. /// /// # Determinism /// @@ -216,34 +221,32 @@ impl CoreWallet { } let hash = signed_msg_hash(message); - // Every signer failure becomes `MessageSigningFailed` (→ `ErrorUnknown`), - // including a key-unavailable one. That is a known limitation, not an - // oversight, and it cannot be fixed here. - // - // `preserve_signer_key_unavailable_or` — this crate's own helper for - // exactly this problem — takes a `dash_sdk::Error` and matches - // `Protocol(Generic(s))` with the reserved marker at position 0. It - // serves the STATE-TRANSITION signing paths, whose failures are - // `dash_sdk::Error`. `sign_ecdsa` is a different surface: its error is - // `S::Error`, bounded only by `Display + Send + Sync + 'static`, so - // there is no enum here to match — passing it to that helper will not - // type-check. The one production impl - // (`rs_sdk_ffi::MnemonicResolverCoreSigner`, `Error = - // MnemonicResolverSignerError`) never stamps the marker either, so even - // a position-0 check on `e.to_string()` would have no producer, while a - // `contains` check is the substring sniff #4183's review rejected. - // - // Closing it needs an upstream change — the signer rendering its - // key-unavailable variants with the marker at position 0, or key-wallet - // tightening `Signer::Error` to something matchable. See the NOTE on - // the `MessageSigningFailed` arm in `platform-wallet-ffi`'s error - // conversion for the full chain. + // `sign_ecdsa`'s error is the associated type `S::Error`, bounded only + // by `Display + Send + Sync + 'static` — there is no enum to match, so + // `preserve_signer_key_unavailable_or` (which takes a `dash_sdk::Error` + // and serves the state-transition signing paths) does not apply here. + // The one typed signal a `Display`-only surface can carry is the + // reserved machine marker a signer stamps at POSITION 0 of its own + // rendering (`MnemonicResolverCoreSigner::NotFound` in production), so + // key unavailability is recognized by that position-0 check — never a + // substring sniff (#4183 review) — and it must happen BEFORE the + // "signer rejected the digest at {path}: " context is prepended, which + // would push the marker mid-string where no permitted check can see it. let (signature, public_key) = signer .sign_ecdsa(&path, hash.to_byte_array()) .await - .map_err(|e| PlatformWalletError::MessageSigningFailed { - address: target.to_string(), - reason: format!("signer rejected the digest at {path}: {e}"), + .map_err(|e| { + let rendered = e.to_string(); + if rendered.starts_with(SIGNER_KEY_UNAVAILABLE_PREFIX) { + PlatformWalletError::MessageSigningKeyUnavailable { + address: target.to_string(), + } + } else { + PlatformWalletError::MessageSigningFailed { + address: target.to_string(), + reason: format!("signer rejected the digest at {path}: {rendered}"), + } + } })?; // Signers return compressed public keys, so the address the signed @@ -548,9 +551,9 @@ mod tests { } /// A digest-capable backend that fails every signature the way a Keystore - /// or Keychain reports a missing key: its `Display` begins with the reserved - /// key-unavailable marker, the representation the state-transition signing - /// paths rely on to recover FFI code 31. + /// or Keychain reports a missing key: its `Display` begins with the + /// reserved key-unavailable marker at position 0, exactly as + /// `MnemonicResolverCoreSigner::NotFound` renders in production. struct KeyUnavailableSigner; #[async_trait::async_trait] @@ -577,24 +580,17 @@ mod tests { } } - /// **Pins a known limitation, so nobody has to re-derive it.** A signer that - /// reports key-unavailable during message signing does NOT reach FFI code - /// 31; it lands on `MessageSigningFailed`, which the FFI flattens to - /// `ErrorUnknown`. - /// - /// The mechanism is visible in the assertion: the marker survives, but - /// MID-STRING, because `reason` is composed as - /// "signer rejected the digest at {path}: {e}". A position-0 match — the - /// only kind #4183's review permits — therefore cannot see it, and - /// `preserve_signer_key_unavailable_or` cannot be applied because it takes a - /// `dash_sdk::Error` while `sign_ecdsa` yields `S::Error: Display`. + /// **The signer-reported missing key is a typed condition.** A signer whose + /// failure rendering starts with the reserved key-unavailable marker maps + /// to `MessageSigningKeyUnavailable` — FFI code 31, the host's key-repair + /// route — never to `MessageSigningFailed`, whose context prefix would bury + /// the marker mid-string and flatten the condition to `ErrorUnknown`. /// - /// If an upstream change ever makes this reachable as code 31 (the signer - /// stamping the marker at position 0 of its own error, or key-wallet - /// tightening `Signer::Error`), THIS TEST SHOULD FAIL — flip it to assert - /// `MessageSigningKeyUnavailable` and delete the surrounding notes. + /// The promotion is `sign_message`'s own position-0 check on the signer's + /// rendering BEFORE any context is added; the FFI boundary maps the typed + /// variant and parses nothing. #[tokio::test] - async fn signer_key_unavailable_is_not_preserved_during_message_signing() { + async fn signer_key_unavailable_is_typed_during_message_signing() { let (wm, wallet_id, _, address) = mnemonic_wallet_manager(MESSAGE_SIGNING_TEST_MNEMONIC).await; let core = core_wallet(wm, wallet_id); @@ -603,16 +599,62 @@ mod tests { .sign_message(&address.to_string(), MESSAGE, &KeyUnavailableSigner) .await; + match result { + Err(PlatformWalletError::MessageSigningKeyUnavailable { address: reported }) => { + assert_eq!(reported, address.to_string()); + } + other => panic!("expected MessageSigningKeyUnavailable, got {other:?}"), + } + } + + /// A digest-capable backend whose failure merely MENTIONS the reserved + /// marker after position 0 — the shape a foreign signer produces when its + /// human-readable text quotes another error. + struct MidStringMarkerSigner; + + #[async_trait::async_trait] + impl Signer for MidStringMarkerSigner { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + _sighash: [u8; 32], + ) -> Result<(ecdsa::Signature, PublicKey), Self::Error> { + Err(format!( + "remote signer reported: {}oops", + crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX + )) + } + + async fn public_key(&self, _path: &DerivationPath) -> Result { + panic!("public_key is not part of the signed-message path"); + } + } + + /// The marker only counts at position 0 of the signer's rendering: a + /// mid-string mention stays `MessageSigningFailed`, never key-unavailable — + /// promoting it would be the substring sniff #4183's review rejected, and + /// would misroute a generic failure into the host's key repair. + #[tokio::test] + async fn mid_string_marker_is_not_promoted_during_message_signing() { + let (wm, wallet_id, _, address) = + mnemonic_wallet_manager(MESSAGE_SIGNING_TEST_MNEMONIC).await; + let core = core_wallet(wm, wallet_id); + + let result = core + .sign_message(&address.to_string(), MESSAGE, &MidStringMarkerSigner) + .await; + match result { Err(PlatformWalletError::MessageSigningFailed { reason, .. }) => { assert!( reason.contains(crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX), - "the signer's marker should still be present: {reason:?}" - ); - assert!( - !reason.starts_with(crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX), - "the marker is mid-string once wrapped, which is exactly why a \ - position-0 match cannot recover it: {reason:?}" + "the signer's own text is preserved in the reason: {reason:?}" ); } other => panic!("expected MessageSigningFailed, got {other:?}"), diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index 14eeb131476..b0c1a3c4d3c 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -109,7 +109,21 @@ pub enum MnemonicResolverSignerError { /// The Swift-side resolver reported that no mnemonic is stored for /// the wallet_id this signer was constructed with. Translates the /// FFI `NOT_FOUND` return code. - #[error("mnemonic not found in keychain for the given wallet_id")] + /// + /// Renders with + /// [`DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX`](crate::signer::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX) + /// at position 0: this is the missing-key completion of the + /// `Signer` surface, whose error is only `Display`, so the reserved + /// machine marker at the START of the rendering is the one typed + /// signal a caller may recognize (position-0 check, never a + /// substring sniff — dashpay/platform#4183 review). + /// `platform-wallet`'s message signing promotes it to its typed + /// key-unavailable error, which the FFI maps to code 31 + /// (`ErrorSigningKeyUnavailable`). + #[error( + "{}mnemonic not found in keychain for the given wallet_id", + crate::signer::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX + )] NotFound, /// The resolver requested a longer output buffer than this signer @@ -1186,6 +1200,21 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + /// The producer half of the key-unavailable contract: `NotFound` renders + /// with the reserved machine marker at position 0. Consumers of the + /// `Signer` surface (whose error is only `Display`) recognize the missing + /// key by exactly this start-of-rendering marker — a mid-string move + /// would silently break the promotion to FFI code 31 without failing any + /// structural match. + #[test] + fn not_found_renders_the_key_unavailable_marker_at_position_zero() { + let rendered = MnemonicResolverSignerError::NotFound.to_string(); + assert!( + rendered.starts_with(crate::signer::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX), + "NotFound must stamp the reserved marker at position 0, got: {rendered:?}" + ); + } + #[tokio::test] async fn null_handle_surfaces_clean_error() { let signer = unsafe {