diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 52a382340..71086bdd5 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -201,7 +201,10 @@ 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 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 8b9716a84..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 @@ -264,14 +264,17 @@ 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. 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 activation, happens only when the - * state actually changes. 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. + * account badge, and login-failed as a retryable error, unless its kind is + * [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 + * session activation, happens only when the state actually changes. 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. */ fun authStateChanged(state: AuthState) {} diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 55bc8c220..23d05e72a 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -186,9 +186,12 @@ 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 - // 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 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:...)`. func authStateChanged(state: AuthState) { DispatchQueue.main.async { /* render the state */ } diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 8398b48df..6075a8e98 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -377,14 +377,17 @@ 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. 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 activation, happens only - /// when the state actually changes. 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. + /// and `.loginFailed` as a retryable error, unless its `kind` is + /// `.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 + /// activation, happens only when the state actually changes. 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. func authStateChanged(state: AuthState) /// Open a JSON-RPC chain connection and return a host-assigned id, or nil if unsupported. diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift index 08ecbf22d..971c35fd6 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,88 @@ 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 — 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 + /** + * 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/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 35b81f0dd..8b52fa9c4 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -689,12 +689,15 @@ 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. 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 activation, happens only when the state actually - * changes. User cancellation is reported through + * `LoginFailed` as a retryable error unless its `kind` is + * `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 + * activation, happens only when the state actually changes. User + * cancellation is reported through * `NativeTrUApiCore.cancel_login()`. */ func authStateChanged(state: AuthState) @@ -954,12 +957,15 @@ 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. 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 activation, happens only when the state actually - * changes. User cancellation is reported through + * `LoginFailed` as a retryable error unless its `kind` is + * `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 + * activation, happens only when the state actually changes. User + * cancellation is reported through * `NativeTrUApiCore.cancel_login()`. */ open func authStateChanged(state: AuthState) {try! rustCall() { @@ -6154,7 +6160,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() != 46688) { + 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/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 ca8b8941b..a791a4e4c 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/main.rs b/rust/crates/truapi-host-cli/src/main.rs index d9c200de3..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 { - err.to_string().contains("no free StatementStore slot") + 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-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 1ea295ca0..9da789f27 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -654,7 +654,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 3b953dce1..5eb3c4d2a 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1051,6 +1051,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, }, @@ -1060,6 +1063,24 @@ 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 — 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] + Other, +} + /// Host auth UI driven by core-owned [`AuthState`] transitions. pub trait AuthPresenter: Send + Sync { /// Observe an auth state change, in transition order. A pairing host's 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/native.rs b/rust/crates/truapi-server/src/native.rs index 41fd9865b..acdf3b1a5 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -419,12 +419,15 @@ 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. 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 activation, happens only when the state actually - /// changes. User cancellation is reported through + /// `LoginFailed` as a retryable error unless its `kind` is + /// `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 + /// activation, happens only when the state actually changes. User + /// cancellation is reported through /// `NativeTrUApiCore.cancel_login()`. fn auth_state_changed(&self, state: AuthState); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 80fcda211..2d181e989 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 24b71daef..282727e4d 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 @@ -80,6 +82,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!( @@ -89,7 +93,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(()) }); } @@ -106,7 +113,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(()) }); } @@ -243,6 +255,31 @@ mod tests { ); } + #[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 announcing_after_a_restored_session_leaves_connected_alone() { let platform = stub_platform(); @@ -296,6 +333,30 @@ mod tests { ); } + #[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 new file mode 100644 index 000000000..943e6a525 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/login_failure.rs @@ -0,0 +1,114 @@ +//! 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 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; + +/// 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 { + if reports_exhausted_period(reason) { + 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_slot_error_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 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()), + 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", + "The operation couldn't be completed. (SubstrateSdk.JSONRPCError error 1.)", + ] { + 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] 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..09d5d8925 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -955,4 +955,27 @@ mod tests { fn truncated_allowance_entry_has_no_account() { assert!(decode_entry(&[0x42; 32]).is_none()); } + + /// 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())); + 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() + )); + } }