From efb662558639e04dc1d11c114b531dd5b3586fbc Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Fri, 14 Aug 2026 15:41:22 +0200 Subject: [PATCH 1/6] feat(platform): give AuthState::LoginFailed a typed kind --- .../Sources/TrUAPIHost/truapi_platform.swift | 87 ++++++++++++++++++- playground/tests/e2e/dotli-diagnosis.ts | 32 +++++-- .../tests/golden/host-callbacks.ts | 21 ++++- rust/crates/truapi-host-cli/src/platform.rs | 2 +- rust/crates/truapi-platform/src/lib.rs | 17 ++++ rust/crates/truapi-server/src/runtime.rs | 1 + .../truapi-server/src/runtime/auth_state.rs | 18 +++- .../src/runtime/login_failure.rs | 78 +++++++++++++++++ .../truapi-server/src/runtime/sso_pairing.rs | 8 +- 9 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 rust/crates/truapi-server/src/runtime/login_failure.rs diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift index d15683af4..aa2e9f9f9 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift @@ -1370,6 +1370,10 @@ public enum AuthState: Equatable, Hashable { * The last login attempt failed; show the reason and offer a retry. */ case loginFailed( + /** + * What kind of failure this was. Hosts branch on this and treat + * `reason` as display copy only. + */kind: LoginFailureKind, /** * Human-readable failure reason. */reason: String @@ -1409,7 +1413,7 @@ public struct FfiConverterTypeAuthState: FfiConverterRustBuffer { case 3: return .connected(try FfiConverterTypeSessionUiInfo.read(from: &buf) ) - case 4: return .loginFailed(reason: try FfiConverterString.read(from: &buf) + case 4: return .loginFailed(kind: try FfiConverterTypeLoginFailureKind.read(from: &buf), reason: try FfiConverterString.read(from: &buf) ) case 5: return .authenticating @@ -1436,8 +1440,9 @@ public struct FfiConverterTypeAuthState: FfiConverterRustBuffer { FfiConverterTypeSessionUiInfo.write(v1, into: &buf) - case let .loginFailed(reason): + case let .loginFailed(kind,reason): writeInt(&buf, Int32(4)) + FfiConverterTypeLoginFailureKind.write(kind, into: &buf) FfiConverterString.write(reason, into: &buf) @@ -1546,6 +1551,84 @@ public func FfiConverterTypeCreateTransactionReview_lower(_ value: CreateTransac +/** + * Why a login attempt failed, for hosts that need to act on the cause rather + * than only display it. + */ + +public enum LoginFailureKind: Equatable, Hashable { + + /** + * The wallet has no free statement-store allowance slot for this period, + * so it cannot register the device. Deterministic until the period rolls + * over: retrying wastes the user's remaining budget. + */ + case noFreeAllowanceSlots + /** + * Anything else. `reason` carries the detail. + */ + case other + + + + + +} + +#if compiler(>=6) +extension LoginFailureKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeLoginFailureKind: FfiConverterRustBuffer { + typealias SwiftType = LoginFailureKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LoginFailureKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .noFreeAllowanceSlots + + case 2: return .other + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: LoginFailureKind, into buf: inout [UInt8]) { + switch value { + + + case .noFreeAllowanceSlots: + writeInt(&buf, Int32(1)) + + + case .other: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeLoginFailureKind_lift(_ buf: RustBuffer) throws -> LoginFailureKind { + return try FfiConverterTypeLoginFailureKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeLoginFailureKind_lower(_ value: LoginFailureKind) -> RustBuffer { + return FfiConverterTypeLoginFailureKind.lower(value) +} + + + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts index 902bb494d..c310bc128 100644 --- a/playground/tests/e2e/dotli-diagnosis.ts +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -319,9 +319,9 @@ async function waitForSignedIn( signingHost: SigningHostCliProcess, ): Promise { try { - const existingFailure = await latestLoginFailureReason(page); + const existingFailure = await latestLoginFailure(page); if (existingFailure !== null) { - throw new Error(`Login failed: ${existingFailure}`); + throw new Error(formatLoginFailure(existingFailure)); } const outcome = await Promise.race([ page @@ -334,14 +334,18 @@ async function waitForSignedIn( const listener = (event: Event): void => { const state = ( event as CustomEvent< - { tag?: string; reason?: string } | undefined + { tag?: string; kind?: string; reason?: string } | undefined > ).detail; if (state?.tag !== "LoginFailed") { return; } window.removeEventListener("dotli:truapi-auth-state", listener); - reject(new Error(`Login failed: ${state.reason ?? "unknown"}`)); + reject( + new Error( + `Login failed (${state.kind ?? "Other"}): ${state.reason ?? "unknown"}`, + ), + ); }; window.addEventListener("dotli:truapi-auth-state", listener); }), @@ -370,15 +374,29 @@ async function waitForSignedIn( } } -async function latestLoginFailureReason(page: Page): Promise { +/** A login failure the host reported, with the core's typed cause. */ +interface LoginFailure { + kind: string; + reason: string; +} + +/** Name the cause first: `NoFreeAllowanceSlots` cannot succeed on a retry. */ +function formatLoginFailure(failure: LoginFailure): string { + return `Login failed (${failure.kind}): ${failure.reason}`; +} + +async function latestLoginFailure(page: Page): Promise { return await page.evaluate(() => { const states = window.__dotliE2eAuthStates ?? []; for (let i = states.length - 1; i >= 0; i--) { const candidate = states[i] as { - detail?: { tag?: string; reason?: string }; + detail?: { tag?: string; kind?: string; reason?: string }; }; if (candidate.detail?.tag === "LoginFailed") { - return candidate.detail.reason ?? "unknown"; + return { + kind: candidate.detail.kind ?? "Other", + reason: candidate.detail.reason ?? "unknown", + }; } } return null; diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index c52bfd9e2..4f534ce88 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -100,7 +100,7 @@ export type AuthState = /** * The last login attempt failed; show the reason and offer a retry. */ - | { tag: "LoginFailed"; value: { reason: string } } + | { tag: "LoginFailed"; value: { kind: LoginFailureKind; reason: string } } /** * The wallet accepted the pairing request and the core is resolving and * persisting the session. Hosts should replace the pairing QR with an @@ -236,6 +236,12 @@ export interface IdentityDisclosureReview { productId: string; } +/** + * Why a login attempt failed, for hosts that need to act on the cause rather + * than only display it. + */ +export type LoginFailureKind = "NoFreeAllowanceSlots" | "Other"; + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. @@ -491,7 +497,10 @@ export const AuthState: S.Codec = S.lazy( Disconnected: S._void, Pairing: S.Struct({ deeplink: S.str }) as S.Codec<{ deeplink: string }>, Connected: SessionUiInfo, - LoginFailed: S.Struct({ reason: S.str }) as S.Codec<{ reason: string }>, + LoginFailed: S.Struct({ + kind: LoginFailureKind, + reason: S.str, + }) as S.Codec<{ kind: LoginFailureKind; reason: string }>, Authenticating: S._void, }), ); @@ -586,6 +595,14 @@ export const IdentityDisclosureReview: S.Codec = S.Struct({ productId: S.str }) as S.Codec, ); +/** + * Why a login attempt failed, for hosts that need to act on the cause rather + * than only display it. + */ +export const LoginFailureKind: S.Codec = S.lazy( + (): S.Codec => S.Status("NoFreeAllowanceSlots", "Other"), +); + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 121ac8c52..402209b0f 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -648,7 +648,7 @@ impl truapi_platform::AuthPresenter for CliPlatform { AuthState::Disconnected => { ("disconnected".to_string(), SystemEvent::PairingDisconnected) } - AuthState::LoginFailed { reason } => ( + AuthState::LoginFailed { reason, .. } => ( "failed".to_string(), SystemEvent::PairingFailed { reason: reason.clone(), diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 168b74632..d084e54c2 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1052,6 +1052,9 @@ pub enum AuthState { Connected(SessionUiInfo), /// The last login attempt failed; show the reason and offer a retry. LoginFailed { + /// What kind of failure this was. Hosts branch on this and treat + /// `reason` as display copy only. + kind: LoginFailureKind, /// Human-readable failure reason. reason: String, }, @@ -1061,6 +1064,20 @@ pub enum AuthState { Authenticating, } +/// Why a login attempt failed, for hosts that need to act on the cause rather +/// than only display it. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum LoginFailureKind { + /// The wallet has no free statement-store allowance slot for this period, + /// so it cannot register the device. Deterministic until the period rolls + /// over: retrying wastes the user's remaining budget. + NoFreeAllowanceSlots, + /// Anything else. `reason` carries the detail. + #[default] + Other, +} + /// Host auth UI driven by core-owned [`AuthState`] transitions. pub trait AuthPresenter: Send + Sync { /// Observe an auth state change. Emitted only when the state actually diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 8b983d619..3635d72dc 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -15,6 +15,7 @@ mod authority; pub(crate) mod bulletin_rpc; mod chat; mod identity; +pub(crate) mod login_failure; mod pairing_host; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. diff --git a/rust/crates/truapi-server/src/runtime/auth_state.rs b/rust/crates/truapi-server/src/runtime/auth_state.rs index e79133160..2cbb0e4a4 100644 --- a/rust/crates/truapi-server/src/runtime/auth_state.rs +++ b/rust/crates/truapi-server/src/runtime/auth_state.rs @@ -5,7 +5,9 @@ use std::sync::{Arc, Mutex}; use futures::channel::oneshot; -use truapi_platform::{AuthPresenter, AuthState, Platform, SessionUiInfo}; +use truapi_platform::{AuthPresenter, AuthState, LoginFailureKind, Platform, SessionUiInfo}; + +use crate::runtime::login_failure::classify_login_failure; /// Serialized auth-state machine bound to the platform's `auth_state_changed` /// sink. Each transition mutates under the lock, releases it, then emits the @@ -71,6 +73,8 @@ impl AuthStateMachine { } /// Active login -> `LoginFailed`: the in-flight login reported a failure. + /// The kind is recovered from `reason`, which is the only form the wallet + /// reports a refusal in. pub(super) fn login_failed(&self, reason: String) { self.transition(|inner| { if !matches!( @@ -80,7 +84,10 @@ impl AuthStateMachine { return None; } inner.cancel_tx = None; - inner.state = AuthState::LoginFailed { reason }; + inner.state = AuthState::LoginFailed { + kind: classify_login_failure(&reason), + reason, + }; Some(()) }); } @@ -97,7 +104,12 @@ impl AuthStateMachine { ) { return None; } - inner.state = AuthState::LoginFailed { reason }; + // Pre-pairing failures are the pairing host's own (device identity, + // bootstrap); allowance exhaustion is only ever wallet-reported. + inner.state = AuthState::LoginFailed { + kind: LoginFailureKind::Other, + reason, + }; Some(()) }); } diff --git a/rust/crates/truapi-server/src/runtime/login_failure.rs b/rust/crates/truapi-server/src/runtime/login_failure.rs new file mode 100644 index 000000000..b50408ac7 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/login_failure.rs @@ -0,0 +1,78 @@ +//! Classification of login failures into [`LoginFailureKind`]. +//! +//! A wallet reports why it refused pairing as prose, over +//! `EncryptedResponse::Failed` on the inter-host wire, so the core recovers the +//! discriminant here instead of leaving every host to pattern-match the text. +//! The wording the wallet sends originates from this workspace's own +//! `SlotError` `Display` impls, and the tests below pin the classifier to them: +//! rewording one fails here rather than silently turning a host's fast-fail +//! back into a retry loop. + +use truapi_platform::LoginFailureKind; + +/// Markers that identify an exhausted statement-store allowance period. Every +/// `SlotError` variant that means "no slot is available to take" renders one of +/// these. +const NO_FREE_SLOT_MARKERS: &[&str] = &["no free statementstore slot", "no free long-term-storage"]; + +/// Recover the failure kind from a wallet-reported reason. +pub(crate) fn classify_login_failure(reason: &str) -> LoginFailureKind { + let reason = reason.to_ascii_lowercase(); + if NO_FREE_SLOT_MARKERS + .iter() + .any(|marker| reason.contains(marker)) + { + return LoginFailureKind::NoFreeAllowanceSlots; + } + LoginFailureKind::Other +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::statement_allowance::slot::SlotError; + + #[test] + fn exhausted_allowance_periods_are_recognized_from_their_own_display_text() { + for error in [ + SlotError::NoFreeStatementStoreSlot { period: 7, max: 8 }, + SlotError::NoFreeLongTermStorageSlot { period: 7, max: 8 }, + ] { + assert_eq!( + classify_login_failure(&error.to_string()), + LoginFailureKind::NoFreeAllowanceSlots, + "`{error}` must classify as an exhausted allowance period" + ); + } + } + + #[test] + fn other_slot_failures_are_not_reported_as_exhausted_periods() { + for error in [ + SlotError::LongTermStoragePeriodDurationZero, + SlotError::ReplacementRefused { period: 7, seq: 3 }, + SlotError::FreeSlotsAwaitingSubmission { period: 7 }, + ] { + assert_eq!( + classify_login_failure(&error.to_string()), + LoginFailureKind::Other, + "`{error}` is not an exhausted allowance period" + ); + } + } + + #[test] + fn unrelated_reasons_are_other() { + for reason in [ + "", + "user rejected pairing", + "pairing statement-store subscribe failed: timeout", + ] { + assert_eq!( + classify_login_failure(reason), + LoginFailureKind::Other, + "`{reason}` is not an exhausted allowance period" + ); + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index ad08608d5..70d00f3d1 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -935,7 +935,7 @@ mod tests { assert!( auth_states .iter() - .any(|state| matches!(state, AuthState::LoginFailed { reason } if reason == expected_reason)), + .any(|state| matches!(state, AuthState::LoginFailed { reason, .. } if reason == expected_reason)), "wallet failure should be surfaced to the modal: {auth_states:?}" ); } @@ -1128,8 +1128,10 @@ mod tests { .lock() .expect("auth state list mutex poisoned"); assert_eq!(auth_states.len(), 1, "states: {auth_states:?}"); - assert!(matches!(&auth_states[0], AuthState::LoginFailed { reason } - if reason.contains("identity storage unavailable"))); + assert!( + matches!(&auth_states[0], AuthState::LoginFailed { reason, .. } + if reason.contains("identity storage unavailable")) + ); } #[test] From 0d2b6edf6ebac21c9728cba588cfce8f0cb778b9 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Sun, 16 Aug 2026 17:42:41 +0200 Subject: [PATCH 2/6] fix(server): widen the login-failure classifier to wallet wordings --- android/truapi-host/README.md | 4 +- .../kotlin/io/parity/truapi/TrUAPIHost.kt | 4 +- ios/truapi-host/README.md | 4 +- .../Sources/TrUAPIHost/TrUAPIHost.swift | 4 +- .../Sources/TrUAPIHost/truapi_server.swift | 10 ++-- rust/crates/truapi-server/src/native.rs | 4 +- .../truapi-server/src/runtime/auth_state.rs | 49 +++++++++++++++++++ .../src/runtime/login_failure.rs | 47 ++++++++++++------ 8 files changed, 104 insertions(+), 22 deletions(-) diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 7cfd51b69..53a207b13 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -162,7 +162,9 @@ class MyBridge(private val webView: WebView) : HostBridge { // Core-owned auth state stream: render AuthState.Pairing as the pairing // QR sheet, connected/disconnected as the account badge, and login-failed - // as a retryable error. When the user closes the pairing sheet, report it + // as a retryable error, unless its kind is + // LoginFailureKind.NoFreeAllowanceSlots, which cannot succeed again until + // the period rolls over. When the user closes the pairing sheet, report it // with `core.cancelLogin()`. override fun authStateChanged(state: AuthState) { main.post { /* render the state */ } diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 91ac870fe..0ea4b483a 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -260,7 +260,9 @@ interface HostBridge { * Observe an auth state change. The core emits states only when they * actually change, in transition order: render [AuthState.Pairing] as the * pairing QR UI, connected/disconnected as the account badge, and - * login-failed as a retryable error. Report a user dismissal of the pairing + * login-failed as a retryable error, unless its kind is + * [LoginFailureKind.NoFreeAllowanceSlots], which cannot succeed again until + * the period rolls over. Report a user dismissal of the pairing * UI through [TrUAPIHostCore.cancelLogin]. Invoked on the dispatcher thread; * marshal the state to the main thread and return promptly. */ diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index b1071ae2b..1511e87e7 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -147,7 +147,9 @@ final class MyCallbacks: HostCallbacks, @unchecked Sendable { } // Core-owned auth state stream: render `.connected`/`.disconnected` as the - // account badge and `.loginFailed` as a retryable error. This core is a + // account badge and `.loginFailed` as a retryable error, unless its + // `kind` is `.noFreeAllowanceSlots`, which cannot succeed again until the + // period rolls over. This core is a // signing host — it owns the signer and never pairs — so `.pairing` and // `.authenticating` are not emitted and `core.cancelLogin()` is inert. // Activate the session with `core.activateLocalSession(secret:...)`. diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 6a01bf25f..0af143508 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -374,7 +374,9 @@ public protocol HostBridge: AnyObject, Sendable { /// Observe an auth state change. The core emits states only when they /// actually change, in transition order: render `.pairing` as the pairing /// QR UI, `.connected`/`.disconnected` as the account badge, and - /// `.loginFailed` as a retryable error. Report a user dismissal of the + /// `.loginFailed` as a retryable error, unless its `kind` is + /// `.noFreeAllowanceSlots`, which cannot succeed again until the period + /// rolls over. Report a user dismissal of the /// pairing UI through ``TrUAPIHostCore/cancelLogin()``. Invoked on the /// dispatcher thread; hand the state to the main thread and return /// promptly. diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 227e39bbd..fb61d5441 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -662,7 +662,9 @@ public protocol HostCallbacks: AnyObject, Sendable { * Observe an auth state change. Emitted only when the state actually * changes, in transition order: render `Pairing` as the pairing QR UI, * `Connected`/`Disconnected` as the account badge, `LoginFailed` as a - * retryable error. User cancellation is reported through + * retryable error unless its `kind` is `NoFreeAllowanceSlots`, which + * cannot succeed again until the period rolls over. User cancellation is + * reported through * `NativeTrUApiCore.cancel_login()`. */ func authStateChanged(state: AuthState) @@ -924,7 +926,9 @@ open func remotePermission(request: RemotePermission)async throws -> Bool { * Observe an auth state change. Emitted only when the state actually * changes, in transition order: render `Pairing` as the pairing QR UI, * `Connected`/`Disconnected` as the account badge, `LoginFailed` as a - * retryable error. User cancellation is reported through + * retryable error unless its `kind` is `NoFreeAllowanceSlots`, which + * cannot succeed again until the period rolls over. User cancellation is + * reported through * `NativeTrUApiCore.cancel_login()`. */ open func authStateChanged(state: AuthState) {try! rustCall() { @@ -5293,7 +5297,7 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_hostcallbacks_remote_permission() != 25245) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 48975) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 7727) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_read() != 59238) { diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 73955de00..82932df7d 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -420,7 +420,9 @@ pub trait HostCallbacks: Send + Sync { /// Observe an auth state change. Emitted only when the state actually /// changes, in transition order: render `Pairing` as the pairing QR UI, /// `Connected`/`Disconnected` as the account badge, `LoginFailed` as a - /// retryable error. User cancellation is reported through + /// retryable error unless its `kind` is `NoFreeAllowanceSlots`, which + /// cannot succeed again until the period rolls over. User cancellation is + /// reported through /// `NativeTrUApiCore.cancel_login()`. fn auth_state_changed(&self, state: AuthState); diff --git a/rust/crates/truapi-server/src/runtime/auth_state.rs b/rust/crates/truapi-server/src/runtime/auth_state.rs index 2cbb0e4a4..eb86fffc6 100644 --- a/rust/crates/truapi-server/src/runtime/auth_state.rs +++ b/rust/crates/truapi-server/src/runtime/auth_state.rs @@ -200,6 +200,55 @@ mod tests { use super::*; use crate::test_support::stub_platform; + #[test] + fn a_wallet_reported_exhausted_period_reaches_the_host_as_a_typed_kind() { + let platform = stub_platform(); + let machine = AuthStateMachine::new(platform.clone()); + let (_cancel_rx, epoch) = machine + .pairing_started("polkadotapp://pair".to_string()) + .expect("login should start"); + machine.authentication_started(epoch); + + machine.login_failed("no free StatementStore slot in period 7 (max 8)".to_string()); + + assert_eq!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .last(), + Some(&AuthState::LoginFailed { + kind: LoginFailureKind::NoFreeAllowanceSlots, + reason: "no free StatementStore slot in period 7 (max 8)".to_string(), + }), + "a host must be able to branch on the kind without reading the reason" + ); + } + + #[test] + fn a_pre_pairing_failure_is_never_reported_as_an_exhausted_period() { + let platform = stub_platform(); + let machine = AuthStateMachine::new(platform.clone()); + + // Allowance exhaustion is only ever wallet-reported, so this path does + // not classify even when the text would otherwise match. + machine.login_failed_before_pairing( + "no free StatementStore slot in period 7 (max 8)".to_string(), + ); + + assert_eq!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .last(), + Some(&AuthState::LoginFailed { + kind: LoginFailureKind::Other, + reason: "no free StatementStore slot in period 7 (max 8)".to_string(), + }) + ); + } + #[test] fn pairing_started_refuses_a_second_login_while_authenticating() { let platform = stub_platform(); diff --git a/rust/crates/truapi-server/src/runtime/login_failure.rs b/rust/crates/truapi-server/src/runtime/login_failure.rs index b50408ac7..6fe0af2b2 100644 --- a/rust/crates/truapi-server/src/runtime/login_failure.rs +++ b/rust/crates/truapi-server/src/runtime/login_failure.rs @@ -3,25 +3,22 @@ //! A wallet reports why it refused pairing as prose, over //! `EncryptedResponse::Failed` on the inter-host wire, so the core recovers the //! discriminant here instead of leaving every host to pattern-match the text. -//! The wording the wallet sends originates from this workspace's own -//! `SlotError` `Display` impls, and the tests below pin the classifier to them: -//! rewording one fails here rather than silently turning a host's fast-fail -//! back into a retry loop. +//! +//! The wording is the wallet's, not this workspace's: the reason travels from +//! an external signing host and no producer in this repo emits it. The rule is +//! therefore deliberately broad, and at least as broad as the host-side regexes +//! it replaces — a host that drops its own matching for `kind` must not lose +//! fast-fail. The tests cover both this workspace's `SlotError` renderings and +//! wordings observed from real wallets. use truapi_platform::LoginFailureKind; -/// Markers that identify an exhausted statement-store allowance period. Every -/// `SlotError` variant that means "no slot is available to take" renders one of -/// these. -const NO_FREE_SLOT_MARKERS: &[&str] = &["no free statementstore slot", "no free long-term-storage"]; - /// Recover the failure kind from a wallet-reported reason. pub(crate) fn classify_login_failure(reason: &str) -> LoginFailureKind { let reason = reason.to_ascii_lowercase(); - if NO_FREE_SLOT_MARKERS - .iter() - .any(|marker| reason.contains(marker)) - { + // Every phrasing seen for an exhausted allowance period names both, and no + // other failure this workspace can render names both. + if reason.contains("no free") && reason.contains("slot") { return LoginFailureKind::NoFreeAllowanceSlots; } LoginFailureKind::Other @@ -33,7 +30,7 @@ mod tests { use crate::runtime::statement_allowance::slot::SlotError; #[test] - fn exhausted_allowance_periods_are_recognized_from_their_own_display_text() { + fn exhausted_allowance_periods_are_recognized_from_slot_error_text() { for error in [ SlotError::NoFreeStatementStoreSlot { period: 7, max: 8 }, SlotError::NoFreeLongTermStorageSlot { period: 7, max: 8 }, @@ -46,12 +43,33 @@ mod tests { } } + #[test] + fn wallet_wordings_seen_in_the_wild_are_recognized() { + for reason in [ + "no free statement store slot in period 20486 (max 8)", + "No free slots available (limit=8)", + "no free slot in period 20486", + ] { + assert_eq!( + classify_login_failure(reason), + LoginFailureKind::NoFreeAllowanceSlots, + "`{reason}` must classify as an exhausted allowance period" + ); + } + } + #[test] fn other_slot_failures_are_not_reported_as_exhausted_periods() { for error in [ SlotError::LongTermStoragePeriodDurationZero, SlotError::ReplacementRefused { period: 7, seq: 3 }, SlotError::FreeSlotsAwaitingSubmission { period: 7 }, + SlotError::MissingChainTimestamp, + SlotError::RegistrationVerificationMismatch { + block_hash: "0xabc".to_string(), + period: 7, + seq: 3, + }, ] { assert_eq!( classify_login_failure(&error.to_string()), @@ -67,6 +85,7 @@ mod tests { "", "user rejected pairing", "pairing statement-store subscribe failed: timeout", + "The operation couldn't be completed. (SubstrateSdk.JSONRPCError error 1.)", ] { assert_eq!( classify_login_failure(reason), From 5bb597ea38c231aa4f2434d5654806e717b06fed Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 17 Aug 2026 17:20:21 +0100 Subject: [PATCH 3/6] docs(ios): rewrap the auth-state comment after the merge --- ios/truapi-host/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index c1ae6385b..1eb4ee3b0 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -182,11 +182,11 @@ final class MyCallbacks: HostCallbacks, @unchecked Sendable { } // Core-owned auth state stream: render `.connected`/`.disconnected` as the - // account badge and `.loginFailed` as a retryable error, unless its - // `kind` is `.noFreeAllowanceSlots`, which cannot succeed again until the - // period rolls over. This core is a - // signing host — it owns the signer and never pairs — so `.pairing` and - // `.authenticating` are not emitted and `core.cancelLogin()` is inert. + // account badge and `.loginFailed` as a retryable error, unless its `kind` + // is `.noFreeAllowanceSlots`, which cannot succeed again until the period + // rolls over. This core is a signing host — it owns the signer and never + // pairs — so `.pairing` and `.authenticating` are not emitted and + // `core.cancelLogin()` is inert. // Activate the session with `core.activateLocalSession(secret:...)`. func authStateChanged(state: AuthState) { DispatchQueue.main.async { /* render the state */ } From 421873cae90d73e115b74d89b3b7d403dc8d77a8 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 17 Aug 2026 19:00:08 +0100 Subject: [PATCH 4/6] refactor(server,cli): share one exhausted-period predicate The signing host's account rotation and the login-failure classifier both recover "this period has no slot left" from error text. Move the rule to statement_allowance::slot as reports_exhausted_period, beside the SlotError Display strings it mirrors, and call it from both. The CLI copy was case-sensitive and matched only the statement-store rendering. --- rust/crates/truapi-host-cli/src/main.rs | 2 +- .../src/runtime/login_failure.rs | 14 ++++---- .../src/runtime/statement_allowance/slot.rs | 33 +++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index e99e94c55..c819bad37 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -1348,7 +1348,7 @@ async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &s } fn is_statement_slot_exhaustion(err: &anyhow::Error) -> bool { - err.to_string().contains("no free StatementStore slot") + truapi_server::statement_allowance::slot::reports_exhausted_period(&err.to_string()) } /// Best-effort: record the pairing allowance accounts in the renewal ledger so diff --git a/rust/crates/truapi-server/src/runtime/login_failure.rs b/rust/crates/truapi-server/src/runtime/login_failure.rs index 6fe0af2b2..2f65a480a 100644 --- a/rust/crates/truapi-server/src/runtime/login_failure.rs +++ b/rust/crates/truapi-server/src/runtime/login_failure.rs @@ -8,17 +8,19 @@ //! an external signing host and no producer in this repo emits it. The rule is //! therefore deliberately broad, and at least as broad as the host-side regexes //! it replaces — a host that drops its own matching for `kind` must not lose -//! fast-fail. The tests cover both this workspace's `SlotError` renderings and -//! wordings observed from real wallets. +//! fast-fail. It lives in +//! [`statement_allowance::slot`](crate::runtime::statement_allowance::slot), +//! beside the `SlotError` `Display` strings it mirrors, so the signing host's +//! own account rotation reads the same rule. The tests cover both this +//! workspace's `SlotError` renderings and wordings observed from real wallets. use truapi_platform::LoginFailureKind; +use crate::runtime::statement_allowance::slot::reports_exhausted_period; + /// Recover the failure kind from a wallet-reported reason. pub(crate) fn classify_login_failure(reason: &str) -> LoginFailureKind { - let reason = reason.to_ascii_lowercase(); - // Every phrasing seen for an exhausted allowance period names both, and no - // other failure this workspace can render names both. - if reason.contains("no free") && reason.contains("slot") { + if reports_exhausted_period(reason) { return LoginFailureKind::NoFreeAllowanceSlots; } LoginFailureKind::Other diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index 55a64b35c..af5cb5658 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -107,6 +107,20 @@ pub enum SlotError { }, } +/// Whether `text` reports an allowance period with no slot left. +/// +/// Lives beside the [`SlotError`] `Display` strings it mirrors so a reworded +/// variant is caught here rather than in whichever caller reads the text. The +/// rule matches on "no free" and "slot" instead of a full rendering because the +/// same fact also arrives as prose from an external wallet, whose wording this +/// workspace does not control, and because a caller that misses the case +/// retries something that cannot succeed until the period rolls over. Callers +/// that need certainty must match [`SlotError`] itself. +pub fn reports_exhausted_period(text: &str) -> bool { + let text = text.to_ascii_lowercase(); + text.contains("no free") && text.contains("slot") +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] struct StatementStoreAllowanceEntry { account_id: [u8; 32], @@ -955,4 +969,23 @@ mod tests { fn truncated_allowance_entry_has_no_account() { assert!(decode_entry(&[0x42; 32]).is_none()); } + + /// The signing host rotates an exhausted auto-managed account off this + /// predicate, and the reason it reads is the registration error wrapped in + /// context, so the match has to survive both the wrapping and the casing. + #[test] + fn an_exhausted_period_is_reported_whatever_wraps_it() { + let error = SlotError::NoFreeStatementStoreSlot { period: 7, max: 10 }; + + assert!(reports_exhausted_period(&error.to_string())); + assert!(reports_exhausted_period(&format!( + "allowance registration for device failed: {error}" + ))); + assert!(reports_exhausted_period( + &SlotError::NoFreeLongTermStorageSlot { period: 7, max: 4 }.to_string() + )); + assert!(!reports_exhausted_period( + &SlotError::FreeSlotsAwaitingSubmission { period: 7 }.to_string() + )); + } } From 6eac4c982e3d64a97939c538ff2964c102b4a416 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 17 Aug 2026 19:00:09 +0100 Subject: [PATCH 5/6] docs(platform): mark NoFreeAllowanceSlots a hint, not a proof The kind is recovered from wallet prose this workspace does not control, so a transient "no free slot" wording classifies the same way. Say so on the variant and in the five host-facing copies: retry should not be the primary action, rather than being impossible. --- android/truapi-host/README.md | 5 +++-- .../src/main/kotlin/io/parity/truapi/TrUAPIHost.kt | 5 +++-- ios/truapi-host/README.md | 5 +++-- ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift | 5 +++-- .../Sources/TrUAPIHost/truapi_platform.swift | 8 ++++++-- .../Sources/TrUAPIHost/truapi_server.swift | 12 +++++++----- rust/crates/truapi-platform/src/lib.rs | 8 ++++++-- rust/crates/truapi-server/src/native.rs | 5 +++-- 8 files changed, 34 insertions(+), 19 deletions(-) diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 5a9905eb0..7a58506a0 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -198,8 +198,9 @@ class MyBridge(private val webView: WebView) : HostBridge { // Core-owned auth state stream: render AuthState.Pairing as the pairing // QR sheet, connected/disconnected as the account badge, and login-failed // as a retryable error, unless its kind is - // LoginFailureKind.NoFreeAllowanceSlots, which cannot succeed again until - // the period rolls over. When the user closes the pairing sheet, report it + // LoginFailureKind.NoFreeAllowanceSlots, which is unlikely to succeed + // before the period rolls over, so retry should not be the primary action. + // When the user closes the pairing sheet, report it // with `core.cancelLogin()`. override fun authStateChanged(state: AuthState) { main.post { /* render the state */ } diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index a411ca3e3..e1aa4726c 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -265,8 +265,9 @@ interface HostBridge { * Observe an auth state change, in transition order: render * [AuthState.Pairing] as the pairing QR UI, connected/disconnected as the * account badge, and login-failed as a retryable error, unless its kind is - * [LoginFailureKind.NoFreeAllowanceSlots], which cannot succeed again until - * the period rolls over. A pairing host's session activation reports its + * [LoginFailureKind.NoFreeAllowanceSlots], which is unlikely to succeed + * before the period rolls over, so retry should not be the primary action. + * A pairing host's session activation reports its * outcome even when it is the default disconnected, so a host that awaits * activation before routing never has to read silence as "signed out"; * every other emission, and every emission on a host role that has no diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 1eb4ee3b0..c8d075e68 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -183,8 +183,9 @@ final class MyCallbacks: HostCallbacks, @unchecked Sendable { // Core-owned auth state stream: render `.connected`/`.disconnected` as the // account badge and `.loginFailed` as a retryable error, unless its `kind` - // is `.noFreeAllowanceSlots`, which cannot succeed again until the period - // rolls over. This core is a signing host — it owns the signer and never + // is `.noFreeAllowanceSlots`, which is unlikely to succeed before the + // period rolls over, so retry should not be the primary action. This core + // is a signing host — it owns the signer and never // pairs — so `.pairing` and `.authenticating` are not emitted and // `core.cancelLogin()` is inert. // Activate the session with `core.activateLocalSession(secret:...)`. diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 2ad9f17ec..6075a8e98 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -378,8 +378,9 @@ public protocol HostBridge: AnyObject, Sendable { /// Observe an auth state change, in transition order: render `.pairing` as /// the pairing QR UI, `.connected`/`.disconnected` as the account badge, /// and `.loginFailed` as a retryable error, unless its `kind` is - /// `.noFreeAllowanceSlots`, which cannot succeed again until the period - /// rolls over. A pairing host's session activation reports its outcome even + /// `.noFreeAllowanceSlots`, which is unlikely to succeed before the period + /// rolls over, so retry should not be the primary action. A pairing host's + /// session activation reports its outcome even /// when it is the default `.disconnected`, so a host that awaits activation /// before routing never has to read silence as "signed out"; every other /// emission, and every emission on a host role that has no session diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift index 2acfd0963..971c35fd6 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift @@ -1560,8 +1560,12 @@ public enum LoginFailureKind: Equatable, Hashable { /** * The wallet has no free statement-store allowance slot for this period, - * so it cannot register the device. Deterministic until the period rolls - * over: retrying wastes the user's remaining budget. + * so it cannot register the device — which normally holds until the period + * rolls over, making a retry a waste of the user's remaining budget. + * + * Recovered heuristically from the wallet's prose, whose wording is not + * this workspace's to pin, so treat it as a strong hint rather than a + * proof: do not make retry the primary action, but leave a way to reach it. */ case noFreeAllowanceSlots /** diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 8c98fd07a..c41f943bd 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -690,8 +690,9 @@ public protocol HostCallbacks: AnyObject, Sendable { * Observe an auth state change, in transition order: render `Pairing` as * the pairing QR UI, `Connected`/`Disconnected` as the account badge, * `LoginFailed` as a retryable error unless its `kind` is - * `NoFreeAllowanceSlots`, which cannot succeed again until the period - * rolls over. A pairing host's session activation reports its outcome even + * `NoFreeAllowanceSlots`, which is unlikely to succeed before the period + * rolls over, so retry should not be the primary action. A pairing host's + * session activation reports its outcome even * when it is the default `Disconnected`, so a host that awaits activation * before routing never has to read silence as "signed out". Every other * emission, and every emission on a host role that has no session @@ -957,8 +958,9 @@ open func remotePermission(request: RemotePermission)async throws -> Bool { * Observe an auth state change, in transition order: render `Pairing` as * the pairing QR UI, `Connected`/`Disconnected` as the account badge, * `LoginFailed` as a retryable error unless its `kind` is - * `NoFreeAllowanceSlots`, which cannot succeed again until the period - * rolls over. A pairing host's session activation reports its outcome even + * `NoFreeAllowanceSlots`, which is unlikely to succeed before the period + * rolls over, so retry should not be the primary action. A pairing host's + * session activation reports its outcome even * when it is the default `Disconnected`, so a host that awaits activation * before routing never has to read silence as "signed out". Every other * emission, and every emission on a host role that has no session @@ -6109,7 +6111,7 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_hostcallbacks_remote_permission() != 25245) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 23332) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 41678) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_read() != 59238) { diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 373bfc992..5eb3c4d2a 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1069,8 +1069,12 @@ pub enum AuthState { #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum LoginFailureKind { /// The wallet has no free statement-store allowance slot for this period, - /// so it cannot register the device. Deterministic until the period rolls - /// over: retrying wastes the user's remaining budget. + /// so it cannot register the device — which normally holds until the period + /// rolls over, making a retry a waste of the user's remaining budget. + /// + /// Recovered heuristically from the wallet's prose, whose wording is not + /// this workspace's to pin, so treat it as a strong hint rather than a + /// proof: do not make retry the primary action, but leave a way to reach it. NoFreeAllowanceSlots, /// Anything else. `reason` carries the detail. #[default] diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index cbc0c659f..acdf3b1a5 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -420,8 +420,9 @@ pub trait HostCallbacks: Send + Sync { /// Observe an auth state change, in transition order: render `Pairing` as /// the pairing QR UI, `Connected`/`Disconnected` as the account badge, /// `LoginFailed` as a retryable error unless its `kind` is - /// `NoFreeAllowanceSlots`, which cannot succeed again until the period - /// rolls over. A pairing host's session activation reports its outcome even + /// `NoFreeAllowanceSlots`, which is unlikely to succeed before the period + /// rolls over, so retry should not be the primary action. A pairing host's + /// session activation reports its outcome even /// when it is the default `Disconnected`, so a host that awaits activation /// before routing never has to read silence as "signed out". Every other /// emission, and every emission on a host role that has no session From 193f4c991943fd843b76d77fbaef8a7ab03eee88 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Mon, 17 Aug 2026 19:23:18 +0100 Subject: [PATCH 6/6] fix(server): keep the exhausted-period predicate off the allowance allocator statement_allowance is cfg'd out on wasm32, and the browser host classifies login failures, so hosting the predicate in its slot module broke the wasm32 build. It lives in runtime::login_failure and is re-exported as truapi_server::reports_exhausted_period; the test pinning it against the SlotError renderings stays beside those strings. --- rust/crates/truapi-host-cli/src/main.rs | 2 +- rust/crates/truapi-server/src/lib.rs | 1 + .../src/runtime/login_failure.rs | 27 ++++++++++++++----- .../src/runtime/statement_allowance/slot.rs | 24 +++++------------ 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index c524ea666..b833be7c1 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -1348,7 +1348,7 @@ async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &s } fn is_statement_slot_exhaustion(err: &anyhow::Error) -> bool { - truapi_server::statement_allowance::slot::reports_exhausted_period(&err.to_string()) + truapi_server::reports_exhausted_period(&err.to_string()) } /// Best-effort: record the pairing allowance accounts in the renewal ledger so diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index d4dc3f8e9..2e775d0c9 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -56,6 +56,7 @@ pub use host_logic::session::{ pub use runtime::ResponderExit; #[cfg(not(target_arch = "wasm32"))] pub use runtime::StatementRenewalTarget; +pub use runtime::login_failure::reports_exhausted_period; #[cfg(not(target_arch = "wasm32"))] pub use runtime::statement_allowance; pub use truapi_platform::{ diff --git a/rust/crates/truapi-server/src/runtime/login_failure.rs b/rust/crates/truapi-server/src/runtime/login_failure.rs index 2f65a480a..943e6a525 100644 --- a/rust/crates/truapi-server/src/runtime/login_failure.rs +++ b/rust/crates/truapi-server/src/runtime/login_failure.rs @@ -8,15 +8,30 @@ //! an external signing host and no producer in this repo emits it. The rule is //! therefore deliberately broad, and at least as broad as the host-side regexes //! it replaces — a host that drops its own matching for `kind` must not lose -//! fast-fail. It lives in -//! [`statement_allowance::slot`](crate::runtime::statement_allowance::slot), -//! beside the `SlotError` `Display` strings it mirrors, so the signing host's -//! own account rotation reads the same rule. The tests cover both this -//! workspace's `SlotError` renderings and wordings observed from real wallets. +//! fast-fail. The tests cover both this workspace's `SlotError` renderings and +//! wordings observed from real wallets. use truapi_platform::LoginFailureKind; -use crate::runtime::statement_allowance::slot::reports_exhausted_period; +/// Whether `text` reports an allowance period with no slot left. +/// +/// The one rule for that question: the signing host reads it to rotate an +/// exhausted auto-managed account, and [`classify_login_failure`] reads it to +/// type a wallet's refusal. It mirrors the +/// [`SlotError`](crate::runtime::statement_allowance::slot::SlotError) +/// `Display` strings, which a test beside those strings pins, and lives here +/// rather than beside them because the wasm32 host classifies login failures +/// without compiling the allowance allocator. +/// +/// The rule matches on "no free" and "slot" instead of a full rendering because +/// the same fact also arrives as prose from an external wallet, whose wording +/// this workspace does not control, and because a caller that misses the case +/// retries something that will not succeed until the period rolls over. +/// Callers that need certainty must match `SlotError` itself. +pub fn reports_exhausted_period(text: &str) -> bool { + let text = text.to_ascii_lowercase(); + text.contains("no free") && text.contains("slot") +} /// Recover the failure kind from a wallet-reported reason. pub(crate) fn classify_login_failure(reason: &str) -> LoginFailureKind { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index af5cb5658..09d5d8925 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -107,20 +107,6 @@ pub enum SlotError { }, } -/// Whether `text` reports an allowance period with no slot left. -/// -/// Lives beside the [`SlotError`] `Display` strings it mirrors so a reworded -/// variant is caught here rather than in whichever caller reads the text. The -/// rule matches on "no free" and "slot" instead of a full rendering because the -/// same fact also arrives as prose from an external wallet, whose wording this -/// workspace does not control, and because a caller that misses the case -/// retries something that cannot succeed until the period rolls over. Callers -/// that need certainty must match [`SlotError`] itself. -pub fn reports_exhausted_period(text: &str) -> bool { - let text = text.to_ascii_lowercase(); - text.contains("no free") && text.contains("slot") -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] struct StatementStoreAllowanceEntry { account_id: [u8; 32], @@ -970,11 +956,15 @@ mod tests { assert!(decode_entry(&[0x42; 32]).is_none()); } - /// The signing host rotates an exhausted auto-managed account off this - /// predicate, and the reason it reads is the registration error wrapped in - /// context, so the match has to survive both the wrapping and the casing. + /// Pins `reports_exhausted_period` against the renderings above: rewording + /// one of these variants fails here, in the file it was reworded in, rather + /// than silently turning the signing host's account rotation into a retry + /// loop. The reason it reads is the registration error wrapped in context, + /// so the match has to survive both the wrapping and the casing. #[test] fn an_exhausted_period_is_reported_whatever_wraps_it() { + use crate::runtime::login_failure::reports_exhausted_period; + let error = SlotError::NoFreeStatementStoreSlot { period: 7, max: 10 }; assert!(reports_exhausted_period(&error.to_string()));