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 {