diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 0487f7a7a..39d23e8b1 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -74,6 +74,39 @@ The core's `Permissions` platform trait has two methods, and so does the bridge: Both return a `Boolean` granted flag; the host renders the typed request in its own prompt UI. The same typed values drive the `TrUAPIHostCore` permission admin API (`permissionAuthorizationStatus`, `setPermissionAuthorizationStatus`), which reads and updates the persisted decisions without prompting. +## Statement-store allowance renewal + +Statement-store allowances are granted per period, so a host has to re-register the accounts it wants to keep writing. They are not revoked the moment the period ends: `Resources.StmtStoreGraceWindow` keeps an ended period's allowances active until cleanup catches up, 48 hours on `paseo-next-v2`. The core owns the ledger and the registration; the app owns only the schedule. + +Record the accounts to keep allowed. This needs an active session, so call it after `activateLocalSession` or after pairing, not at construction: + +```kotlin +core.trackStatementRenewalTargets( + listOf( + NativeStatementRenewalTarget.WalletSso, + NativeStatementRenewalTarget.Account(deviceStatementKey, "device"), + ), +) +``` + +The ledger persists across launches, and it is append-only: there is no untrack, and an entry is dropped only when the identity that promised it changes. `WalletSso` and `ProductStatementAllowance` are derivation recipes and survive that; `Account` carries a fixed account id and does not, so re-track raw accounts whenever the active identity changes. A pruned target is absent from the report rather than reported as failed. There is no reader and no untrack on this surface: a host cannot list what is tracked, cannot remove a wrong entry, and cannot detect a pruned one except by noticing it missing from a report. Re-tracking is idempotent, so the safe habit is to re-track the full set after every identity change rather than trying to reason about what survived. + +Then run a pass from a `WorkManager` worker. It submits extrinsics and blocks until they are included, so keep it off the main thread. It needs an active session too, which is the whole difficulty here: a worker on a cold start has none until you restore one, and the pass then fails with the bare reason `Disconnected`. Restore the session first, and read that reason as "not ready" rather than as a renewal failure. `startStatementAllowanceRenewal()` does not need this care, since its loop skips a tick with no session and retries. + +```kotlin +val report = core.renewStatementAllowances() +report.outcomes.forEach { Log.i(TAG, "${it.label}: ${it.status}") } +if (report.slotsExhausted) { + // Every slot for this period is taken and none was replaceable. +} +``` + +One scheduled pass per period is enough, with room to spare: an allowance stays usable for `Resources.StmtStoreGraceWindow` past its boundary, which is 48 hours on `paseo-next-v2`, so a missed run is recoverable rather than fatal. `nextStatementRenewalDelay()` reports the in-process loop's retry cadence, capped at an hour; a worker scheduling one run per period should read a value under an hour as the boundary approaching rather than waking hourly. + +`startStatementAllowanceRenewal()` runs the same pass on an in-process loop instead, for a host that stays resident. A pass has no cancellation, so several targets can outlast a constrained worker budget; targets registered before the process is killed are not lost and read back as already allocated. + +An account id must be exactly 32 bytes. Anything else throws `NativeRenewalTargetException.InvalidAccountId` before any chain work happens. + ## Example > **Threading:** the Rust core invokes every `HostBridge` callback on a 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 c388d3f53..8b9716a84 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 @@ -43,8 +43,11 @@ import uniffi.truapi_server.HostNavigateRejection import uniffi.truapi_server.HostRejection import uniffi.truapi_server.HostStorageException import uniffi.truapi_platform.ProductExecutionKind as UniFfiProductExecutionKind +import uniffi.truapi_server.NativeRenewalTargetException import uniffi.truapi_server.NativeRuntimeConfigException +import uniffi.truapi_server.NativeStatementRenewalTarget import uniffi.truapi_server.NativeTrUApiCore +import uniffi.truapi_server.StatementRenewalReport import uniffi.truapi_server.WsBridgeEndpoint import uniffi.truapi_server.WsBridgeStartException import uniffi.truapi_server.NativePairingDeeplinkScheme as UniFfiNativePairingDeeplinkScheme @@ -593,6 +596,51 @@ class TrUAPIHostCore private constructor( inner.activateLocalSession(secret, liteUsername) } + /** + * Record the accounts renewal should keep allowed on the Statement Store. + * Needs an active session, so call it after [activateLocalSession] or after + * pairing, not at construction. + * + * The ledger is append-only: there is no untrack, and a target is only + * dropped when the identity that promised it changes. Recipe-shaped targets + * survive that; a raw [NativeStatementRenewalTarget.Account] does not, so + * re-track those whenever the active identity changes. + */ + @Throws(NativeRenewalTargetException::class) + fun trackStatementRenewalTargets(targets: List) { + inner.trackStatementRenewalTargets(targets) + } + + /** + * Run one renewal pass now, reporting what each tracked target got. + * + * Submits extrinsics and blocks until they are included, so call it from a + * WorkManager worker rather than the main thread. There is no cancellation: + * a pass with several targets can outlast a short background budget, though + * a target registered before the process is killed is not lost and reads + * back as already allocated. + */ + @Throws(HostRejection::class) + fun renewStatementAllowances(): StatementRenewalReport = inner.renewStatementAllowances() + + /** + * Start the in-process renewal loop, for a host that stays resident. A + * suspended app stops ticking, so prefer scheduling + * [renewStatementAllowances]. + */ + fun startStatementAllowanceRenewal() { + inner.startStatementAllowanceRenewal() + } + + /** + * The in-process loop's own cadence, capped at an hour. Allowances only + * stop being renewed at a period boundary and survive it by the chain's + * grace window, so a host scheduling one wake-up per period + * should read a value under an hour as the boundary approaching rather than + * waking hourly. + */ + fun nextStatementRenewalDelay(): java.time.Duration = inner.nextStatementRenewalDelay() + /** Read a stored permission authorization status without prompting. */ @Throws(HostRejection::class) fun permissionAuthorizationStatus( diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index b1071ae2b..8067bb51e 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -96,6 +96,41 @@ The core's `Permissions` platform trait has two methods, and so does `HostCallba Both return a `Bool` granted flag; the host renders the typed request in its own prompt UI. The same typed values drive the `TrUAPIHostCore` permission admin API (`permissionAuthorizationStatus`, `setPermissionAuthorizationStatus`), which reads and updates the persisted decisions without prompting. +## Statement-store allowance renewal + +Statement-store allowances are granted per period, so a host has to re-register the accounts it wants to keep writing. They are not revoked the moment the period ends: `Resources.StmtStoreGraceWindow` keeps an ended period's allowances active until cleanup catches up, 48 hours on `paseo-next-v2`. The runtime owns the ledger and the registration; the app owns only the schedule. + +Record the accounts to keep allowed. This needs an active session, so call it after `activateLocalSession` or after pairing, not at construction: + +```swift +try runtime.trackStatementRenewalTargets([ + .walletSso, + .account(accountId: deviceStatementKey, label: "device"), +]) +``` + +The ledger persists across launches, and it is append-only: there is no untrack, and an entry is dropped only when the identity that promised it changes. `.walletSso` and `.productStatementAllowance` are derivation recipes, so they survive that; `.account` carries a fixed account id and does not. Re-track raw accounts whenever the active identity changes, or renewal quietly stops covering them — a pruned target is absent from the report rather than reported as failed. There is no reader and no untrack on this surface: a host cannot list what is tracked, cannot remove a wrong entry, and cannot detect a pruned one except by noticing it missing from a report. Re-tracking is idempotent, so the safe habit is to re-track the full set after every identity change rather than trying to reason about what survived. + +Then run a pass from a background task, off the main thread. It needs an active session too, which is the whole difficulty here: a `BGTaskScheduler` wake on a cold start has none until you restore one, and the pass then fails with the bare reason `Disconnected`. Restore the session first, and read that reason as "not ready" rather than as a renewal failure. `startStatementAllowanceRenewal()` does not need this care, since its loop skips a tick with no session and retries. + +```swift +let report = try runtime.renewStatementAllowances() +for outcome in report.outcomes { + log("\(outcome.label): \(outcome.status)") +} +if report.slotsExhausted { + // Every slot for this period is taken and none was replaceable. +} +``` + +One scheduled pass per period is enough, with room to spare: an allowance stays usable for `Resources.StmtStoreGraceWindow` past its boundary, which is 48 hours on `paseo-next-v2`, so a missed wake-up is recoverable rather than fatal. `nextStatementRenewalDelay()` reports the in-process loop's retry cadence, capped at an hour; a `BGTaskScheduler` host should read a value under an hour as the boundary approaching rather than requesting a wake-up every hour for a pass that will almost always report `alreadyAllocated`. + +`startStatementAllowanceRenewal()` runs the same pass on an in-process loop instead. It suits a host that stays resident; on iOS a suspended app stops ticking, so prefer `BGTaskScheduler` driving the one-shot call. A pass has no cancellation, so several targets can outlast a short background budget; targets registered before the process is killed are not lost, and read back as already allocated next time. + +An account id must be exactly 32 bytes. Anything else is rejected as `NativeRenewalTargetError.InvalidAccountId` before any chain work happens. + +`TrUAPIHostCore` exposes the same four calls for hosts that use it instead of `TrUAPIHostRuntime`. + ## Example > **Threading:** the Rust core invokes every `HostCallbacks` method on a diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 44e368c4c..8398b48df 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -301,6 +301,10 @@ public protocol TrUAPIHostCoreProtocol: AnyObject { func notifyPreimageChanged(key: Data, value: Data?) func notifyChainResponse(connectionId: UInt32, json: String) func notifyChainClosed(connectionId: UInt32) + func trackStatementRenewalTargets(_ targets: [StatementRenewalTarget]) throws + func renewStatementAllowances() throws -> StatementRenewalReport + func startStatementAllowanceRenewal() + func nextStatementRenewalDelay() -> TimeInterval } /// Product-scoped key-value storage provided by the embedding host. @@ -747,6 +751,69 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { public func notifyChainClosed(connectionId: UInt32) { inner.notifyChainClosed(connectionId: connectionId) } + + /// Record the accounts renewal should keep allowed on the Statement Store. + /// + /// Needs an active session, so call it after + /// ``activateLocalSession(secret:liteUsername:)`` or after pairing, not at + /// construction. + /// + /// Recipe-shaped targets survive a change of root entropy; a raw + /// ``StatementRenewalTarget/account(accountId:label:)`` does not, and is + /// dropped by the next pass after ``activateLocalSession(secret:liteUsername:)`` + /// installs a different identity. Re-track those whenever the identity changes. + public func trackStatementRenewalTargets(_ targets: [StatementRenewalTarget]) throws { + try inner.trackStatementRenewalTargets(targets: targets.map(\.native)) + } + + /// Run one renewal pass now, reporting what each tracked target got. + /// + /// Submits extrinsics and blocks until they are included, so call it off the + /// main thread. There is no cancellation: a pass with several targets can + /// outlast a short background budget, though a target registered before the + /// process is killed is not lost and reads back as already allocated. + public func renewStatementAllowances() throws -> StatementRenewalReport { + try inner.renewStatementAllowances() + } + + /// Start the in-process renewal loop, for a host that stays resident. A + /// suspended app stops ticking, so prefer scheduling + /// ``renewStatementAllowances()``. + public func startStatementAllowanceRenewal() { + inner.startStatementAllowanceRenewal() + } + + /// The in-process loop's own cadence, capped at an hour. Allowances only + /// stop being renewed at a period boundary and survive it by the chain's + /// grace window, so a host scheduling one wake-up per period + /// should read a value under an hour as the boundary approaching rather + /// than waking hourly. + public func nextStatementRenewalDelay() -> TimeInterval { + inner.nextStatementRenewalDelay() + } +} + +/// An account renewal should keep allowed on the Statement Store. +public enum StatementRenewalTarget: Sendable { + /// The statement-store allowance account derived for one product. Resolves + /// under whatever root entropy is active, so it survives a rotation. + case productStatementAllowance(productId: String) + /// The wallet's own SSO account. Also a derivation, so it survives a rotation. + case walletSso + /// A fixed account, such as a pairing peer's device statement key. Must be + /// exactly 32 bytes, and is dropped when the promising identity changes. + case account(accountId: Data, label: String) + + var native: NativeStatementRenewalTarget { + switch self { + case let .productStatementAllowance(productId): + .productStatementAllowance(productId: productId) + case .walletSso: + .walletSso + case let .account(accountId, label): + .account(accountId: accountId, label: label) + } + } } /// Testable surface for one connection-scoped product execution. @@ -934,6 +1001,31 @@ public final class TrUAPIHostCore: TrUAPIHostCoreProtocol { try inner.activateLocalSession(secret: secret, liteUsername: liteUsername) } + /// Record the accounts renewal should keep allowed on the Statement Store. + /// See ``TrUAPIHostRuntime/trackStatementRenewalTargets(_:)``. + public func trackStatementRenewalTargets(_ targets: [StatementRenewalTarget]) throws { + try inner.trackStatementRenewalTargets(targets: targets.map(\.native)) + } + + /// Run one renewal pass now. Blocks until the extrinsics are included, so + /// call it off the main thread. See + /// ``TrUAPIHostRuntime/renewStatementAllowances()``. + public func renewStatementAllowances() throws -> StatementRenewalReport { + try inner.renewStatementAllowances() + } + + /// Start the in-process renewal loop. See + /// ``TrUAPIHostRuntime/startStatementAllowanceRenewal()``. + public func startStatementAllowanceRenewal() { + inner.startStatementAllowanceRenewal() + } + + /// The in-process loop's cadence, capped at an hour. See + /// ``TrUAPIHostRuntime/nextStatementRenewalDelay()``. + public func nextStatementRenewalDelay() -> TimeInterval { + inner.nextStatementRenewalDelay() + } + /// Read a stored permission authorization status without prompting. public func permissionAuthorizationStatus( request: PermissionAuthorizationRequest diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index afa3801b1..a48d3721f 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -604,6 +604,34 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterDuration: FfiConverterRustBuffer { + typealias SwiftType = TimeInterval + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TimeInterval { + let seconds: UInt64 = try readInt(&buf) + let nanoseconds: UInt32 = try readInt(&buf) + return Double(seconds) + (Double(nanoseconds) / 1.0e9) + } + + public static func write(_ value: TimeInterval, into buf: inout [UInt8]) { + if value.rounded(.down) > Double(Int64.max) { + fatalError("Duration overflow, exceeds max bounds supported by Uniffi") + } + + if value < 0 { + fatalError("Invalid duration, must be non-negative") + } + + let seconds = UInt64(value) + let nanoseconds = UInt32((value - Double(seconds)) * 1.0e9) + writeInt(&buf, seconds) + writeInt(&buf, nanoseconds) + } +} + @@ -2763,6 +2791,14 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func disconnect() + /** + * The in-process loop's own cadence, capped at an hour. An allowance stays + * usable for `Resources.StmtStoreGraceWindow` past its boundary, 48 hours + * on `paseo-next-v2`, so a host scheduling one wake-up per period has ample + * slack and should read a value under an hour as the boundary approaching. + */ + func nextStatementRenewalDelay() -> TimeInterval + /** * Notify the core that a native chain connection closed externally. */ @@ -2804,6 +2840,22 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func permissionAuthorizationStatus(request: PermissionAuthorizationRequest) throws -> PermissionAuthorizationStatus + /** + * Run one renewal pass now and report what each tracked target got. + * + * For hosts whose process cannot stay alive between periods: drive it from + * WorkManager or BGTaskScheduler rather than + * [`Self::start_statement_allowance_renewal`]. It submits extrinsics and + * blocks until they are included, so call it from a background thread. + * + * Needs an active session, which is the whole difficulty of the scheduled + * case: an OS-woken cold start has none until the host restores one, and + * the pass then fails with the bare reason `Disconnected`. Restore the + * session before calling, and treat that reason as "not ready" rather than + * as a renewal failure. + */ + func renewStatementAllowances() throws -> StatementRenewalReport + /** * List registered providers for a ring so host UI can present the RFC-0024 * personhood-provider setting. @@ -2831,6 +2883,15 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func setPermissionAuthorizationStatus(request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus) throws + /** + * Start the in-process renewal loop, for a host that stays resident. A + * suspended app stops ticking, so prefer scheduling + * [`Self::renew_statement_allowances`]. Idempotent, and unlike the one-shot + * call it tolerates having no session: a tick without one is skipped and + * retried. + */ + func startStatementAllowanceRenewal() + /** * Start the localhost WebSocket bridge. Returns the descriptor the * host hands to the product so it can dial back in. @@ -2842,6 +2903,21 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func stopWsBridge() + /** + * Record the accounts renewal should keep allowed. The ledger persists, so + * this only has to be called when the set changes, not on every launch. + * Renewal has nothing to do until at least one target is tracked. + * + * Needs an active session, so call it after + * [`Self::activate_local_session`] or after pairing, not at construction. + * + * The ledger is append-only. There is no untrack, and an entry is dropped + * only when the identity that promised it changes, which keeps derivation + * recipes and discards raw account ids. Re-tracking is idempotent, so + * re-track the full set after an identity change. + */ + func trackStatementRenewalTargets(targets: [NativeStatementRenewalTarget]) throws + } /** * Legacy single-execution UniFFI object retained for existing embedders. @@ -2967,6 +3043,21 @@ open func disconnect() {try! rustCall() { self.uniffiCloneHandle(),uniffiCallStatus ) } +} + + /** + * The in-process loop's own cadence, capped at an hour. An allowance stays + * usable for `Resources.StmtStoreGraceWindow` past its boundary, 48 hours + * on `paseo-next-v2`, so a host scheduling one wake-up per period has ample + * slack and should read a value under an hour as the boundary approaching. + */ +open func nextStatementRenewalDelay() -> TimeInterval { + return try! FfiConverterDuration.lift(try! rustCall() { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_next_statement_renewal_delay( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) } /** @@ -3052,6 +3143,29 @@ open func permissionAuthorizationStatus(request: PermissionAuthorizationRequest) FfiConverterTypePermissionAuthorizationRequest_lower(request),uniffiCallStatus ) }) +} + + /** + * Run one renewal pass now and report what each tracked target got. + * + * For hosts whose process cannot stay alive between periods: drive it from + * WorkManager or BGTaskScheduler rather than + * [`Self::start_statement_allowance_renewal`]. It submits extrinsics and + * blocks until they are included, so call it from a background thread. + * + * Needs an active session, which is the whole difficulty of the scheduled + * case: an OS-woken cold start has none until the host restores one, and + * the pass then fails with the bare reason `Disconnected`. Restore the + * session before calling, and treat that reason as "not ready" rather than + * as a renewal failure. + */ +open func renewStatementAllowances()throws -> StatementRenewalReport { + return try FfiConverterTypeStatementRenewalReport_lift(try rustCallWithError(FfiConverterTypeHostRejection_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_renew_statement_allowances( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) } /** @@ -3111,6 +3225,21 @@ open func setPermissionAuthorizationStatus(request: PermissionAuthorizationReque FfiConverterTypePermissionAuthorizationStatus_lower(status),uniffiCallStatus ) } +} + + /** + * Start the in-process renewal loop, for a host that stays resident. A + * suspended app stops ticking, so prefer scheduling + * [`Self::renew_statement_allowances`]. Idempotent, and unlike the one-shot + * call it tolerates having no session: a tick without one is skipped and + * retried. + */ +open func startStatementAllowanceRenewal() {try! rustCall() { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_start_statement_allowance_renewal( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} } /** @@ -3138,6 +3267,28 @@ open func stopWsBridge() {try! rustCall() { } } + /** + * Record the accounts renewal should keep allowed. The ledger persists, so + * this only has to be called when the set changes, not on every launch. + * Renewal has nothing to do until at least one target is tracked. + * + * Needs an active session, so call it after + * [`Self::activate_local_session`] or after pairing, not at construction. + * + * The ledger is append-only. There is no untrack, and an entry is dropped + * only when the identity that promised it changes, which keeps derivation + * recipes and discards raw account ids. Re-tracking is idempotent, so + * re-track the full set after an identity change. + */ +open func trackStatementRenewalTargets(targets: [NativeStatementRenewalTarget])throws {try rustCallWithError(FfiConverterTypeNativeRenewalTargetError_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_track_statement_renewal_targets( + self.uniffiCloneHandle(), + FfiConverterSequenceTypeNativeStatementRenewalTarget.lower(targets),uniffiCallStatus + ) +} +} + } @@ -3203,6 +3354,19 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { */ func disconnect() + /** + * The in-process loop's own cadence: at most an hour, tightening to land + * just after the next period boundary. + * + * The hourly cap is a retry rhythm, not a statement about when work is + * due. An allowance stays usable for `Resources.StmtStoreGraceWindow` past + * its boundary, 48 hours on `paseo-next-v2`, so a host scheduling one OS + * wake-up per period has ample slack and should treat any value under an + * hour as the boundary approaching, rather than requesting a wake every + * hour for a pass that will almost always report `AlreadyAllocated`. + */ + func nextStatementRenewalDelay() -> TimeInterval + /** * Notify the shared chain adapter that a connection closed. */ @@ -3220,6 +3384,39 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { */ func openProductExecution(callbacks: HostCallbacks, chatCallbacks: NativeChatCallbacks?, executionConfig: NativeProductExecutionConfig) throws -> NativeProductExecution + /** + * Run one renewal pass now and report what each tracked target got. + * + * This is the entry point for hosts whose process cannot stay alive + * between periods: drive it from WorkManager or BGTaskScheduler rather + * than [`Self::start_statement_allowance_renewal`]. It submits extrinsics + * and blocks until they are included, so call it from a background thread. + * + * Needs an active session, which is the whole difficulty of the scheduled + * case: an OS-woken cold start has none until the host restores one, and + * the pass then fails with the bare reason `Disconnected`. Restore the + * session before calling, and treat that reason as "not ready" rather than + * as a renewal failure. [`Self::start_statement_allowance_renewal`] does + * not need this care; its loop skips a tick with no session and retries. + */ + func renewStatementAllowances() throws -> StatementRenewalReport + + /** + * Start the in-process renewal loop, for hosts that stay resident. Mobile + * hosts should schedule [`Self::renew_statement_allowances`] instead, + * because a suspended process stops ticking. Idempotent; the loop ends + * when this runtime is dropped. + */ + func startStatementAllowanceRenewal() + + /** + * Record the accounts a renewal pass should keep allowed. The ledger + * persists, so this only has to be called when the set changes, not on + * every launch. Renewal has nothing to do until at least one target is + * tracked. + */ + func trackStatementRenewalTargets(targets: [NativeStatementRenewalTarget]) throws + } /** * Process-owned native TrUAPI runtime shared by all executable connections. @@ -3312,6 +3509,26 @@ open func disconnect() {try! rustCall() { self.uniffiCloneHandle(),uniffiCallStatus ) } +} + + /** + * The in-process loop's own cadence: at most an hour, tightening to land + * just after the next period boundary. + * + * The hourly cap is a retry rhythm, not a statement about when work is + * due. An allowance stays usable for `Resources.StmtStoreGraceWindow` past + * its boundary, 48 hours on `paseo-next-v2`, so a host scheduling one OS + * wake-up per period has ample slack and should treat any value under an + * hour as the boundary approaching, rather than requesting a wake every + * hour for a pass that will almost always report `AlreadyAllocated`. + */ +open func nextStatementRenewalDelay() -> TimeInterval { + return try! FfiConverterDuration.lift(try! rustCall() { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapihostruntime_next_statement_renewal_delay( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) } /** @@ -3356,6 +3573,59 @@ open func openProductExecution(callbacks: HostCallbacks, chatCallbacks: NativeCh }) } + /** + * Run one renewal pass now and report what each tracked target got. + * + * This is the entry point for hosts whose process cannot stay alive + * between periods: drive it from WorkManager or BGTaskScheduler rather + * than [`Self::start_statement_allowance_renewal`]. It submits extrinsics + * and blocks until they are included, so call it from a background thread. + * + * Needs an active session, which is the whole difficulty of the scheduled + * case: an OS-woken cold start has none until the host restores one, and + * the pass then fails with the bare reason `Disconnected`. Restore the + * session before calling, and treat that reason as "not ready" rather than + * as a renewal failure. [`Self::start_statement_allowance_renewal`] does + * not need this care; its loop skips a tick with no session and retries. + */ +open func renewStatementAllowances()throws -> StatementRenewalReport { + return try FfiConverterTypeStatementRenewalReport_lift(try rustCallWithError(FfiConverterTypeHostRejection_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapihostruntime_renew_statement_allowances( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + + /** + * Start the in-process renewal loop, for hosts that stay resident. Mobile + * hosts should schedule [`Self::renew_statement_allowances`] instead, + * because a suspended process stops ticking. Idempotent; the loop ends + * when this runtime is dropped. + */ +open func startStatementAllowanceRenewal() {try! rustCall() { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapihostruntime_start_statement_allowance_renewal( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + + /** + * Record the accounts a renewal pass should keep allowed. The ledger + * persists, so this only has to be called when the set changes, not on + * every launch. Renewal has nothing to do until at least one target is + * tracked. + */ +open func trackStatementRenewalTargets(targets: [NativeStatementRenewalTarget])throws {try rustCallWithError(FfiConverterTypeNativeRenewalTargetError_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapihostruntime_track_statement_renewal_targets( + self.uniffiCloneHandle(), + FfiConverterSequenceTypeNativeStatementRenewalTarget.lower(targets),uniffiCallStatus + ) +} +} + } @@ -3781,6 +4051,155 @@ public func FfiConverterTypeNativeRuntimeConfig_lower(_ value: NativeRuntimeConf } +/** + * What one target's renewal produced, paired with the label that identifies it + * in the ledger. + */ +public struct StatementRenewalOutcome: Equatable, Hashable { + /** + * Ledger label for the renewed target. + */ + public var label: String + /** + * What the pass did for this target. + */ + public var status: TargetRenewalStatus + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Ledger label for the renewed target. + */label: String, + /** + * What the pass did for this target. + */status: TargetRenewalStatus) { + self.label = label + self.status = status + } + + + + +} + +#if compiler(>=6) +extension StatementRenewalOutcome: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeStatementRenewalOutcome: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StatementRenewalOutcome { + return + try StatementRenewalOutcome( + label: FfiConverterString.read(from: &buf), + status: FfiConverterTypeTargetRenewalStatus.read(from: &buf) + ) + } + + public static func write(_ value: StatementRenewalOutcome, into buf: inout [UInt8]) { + FfiConverterString.write(value.label, into: &buf) + FfiConverterTypeTargetRenewalStatus.write(value.status, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStatementRenewalOutcome_lift(_ buf: RustBuffer) throws -> StatementRenewalOutcome { + return try FfiConverterTypeStatementRenewalOutcome.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStatementRenewalOutcome_lower(_ value: StatementRenewalOutcome) -> RustBuffer { + return FfiConverterTypeStatementRenewalOutcome.lower(value) +} + + +/** + * Summary of one renewal pass. + */ +public struct StatementRenewalReport: Equatable, Hashable { + /** + * Period the pass registered for. + */ + public var period: UInt32 + /** + * Per-target outcomes in ledger order. + */ + public var outcomes: [StatementRenewalOutcome] + /** + * Whether the pass hit slot exhaustion for this period. + */ + public var slotsExhausted: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Period the pass registered for. + */period: UInt32, + /** + * Per-target outcomes in ledger order. + */outcomes: [StatementRenewalOutcome], + /** + * Whether the pass hit slot exhaustion for this period. + */slotsExhausted: Bool) { + self.period = period + self.outcomes = outcomes + self.slotsExhausted = slotsExhausted + } + + + + +} + +#if compiler(>=6) +extension StatementRenewalReport: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeStatementRenewalReport: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StatementRenewalReport { + return + try StatementRenewalReport( + period: FfiConverterUInt32.read(from: &buf), + outcomes: FfiConverterSequenceTypeStatementRenewalOutcome.read(from: &buf), + slotsExhausted: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: StatementRenewalReport, into buf: inout [UInt8]) { + FfiConverterUInt32.write(value.period, into: &buf) + FfiConverterSequenceTypeStatementRenewalOutcome.write(value.outcomes, into: &buf) + FfiConverterBool.write(value.slotsExhausted, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStatementRenewalReport_lift(_ buf: RustBuffer) throws -> StatementRenewalReport { + return try FfiConverterTypeStatementRenewalReport.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStatementRenewalReport_lower(_ value: StatementRenewalReport) -> RustBuffer { + return FfiConverterTypeStatementRenewalReport.lower(value) +} + + /** * Per-session descriptor returned to the host: product uses `port + token` * to build its WebSocket URL (e.g. `ws://127.0.0.1:/?t=`). @@ -4199,6 +4618,122 @@ public func FfiConverterTypeNativePairingDeeplinkScheme_lower(_ value: NativePai +/** + * Rejected renewal-target registration. + */ +public +enum NativeRenewalTargetError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + /** + * `account_id` was not exactly 32 bytes. + */ + case InvalidAccountId( + /** + * Supplied byte length. + */actual: UInt64 + ) + /** + * `product_id` is not a usable product identifier. + */ + case InvalidProductId( + /** + * The identifier as supplied. + */productId: String + ) + /** + * The core refused to record the targets. + */ + case Rejected( + /** + * Human-readable rejection reason. + */reason: String + ) + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension NativeRenewalTargetError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNativeRenewalTargetError: FfiConverterRustBuffer { + typealias SwiftType = NativeRenewalTargetError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NativeRenewalTargetError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidAccountId( + actual: try FfiConverterUInt64.read(from: &buf) + ) + case 2: return .InvalidProductId( + productId: try FfiConverterString.read(from: &buf) + ) + case 3: return .Rejected( + reason: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: NativeRenewalTargetError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .InvalidAccountId(actual): + writeInt(&buf, Int32(1)) + FfiConverterUInt64.write(actual, into: &buf) + + + case let .InvalidProductId(productId): + writeInt(&buf, Int32(2)) + FfiConverterString.write(productId, into: &buf) + + + case let .Rejected(reason): + writeInt(&buf, Int32(3)) + FfiConverterString.write(reason, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNativeRenewalTargetError_lift(_ buf: RustBuffer) throws -> NativeRenewalTargetError { + return try FfiConverterTypeNativeRenewalTargetError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNativeRenewalTargetError_lower(_ value: NativeRenewalTargetError) -> RustBuffer { + return FfiConverterTypeNativeRenewalTargetError.lower(value) +} + + /** * Native runtime config validation error. */ @@ -4395,6 +4930,110 @@ public func FfiConverterTypeNativeRuntimeConfigError_lower(_ value: NativeRuntim } +/** + * An account the host wants kept allowed on the Statement Store across + * periods. Mirrors [`crate::runtime::StatementRenewalTarget`] with a + * length-checked `account_id`, because UniFFI carries byte arrays as `Vec` + * rather than a fixed width. + */ + +public enum NativeStatementRenewalTarget: Equatable, Hashable { + + /** + * The statement-store allowance account derived for one product. + */ + case productStatementAllowance( + /** + * Product the allowance account belongs to. + */productId: String + ) + /** + * The wallet's own SSO account. + */ + case walletSso + /** + * A fixed account, such as a pairing peer's device statement key. + */ + case account( + /** + * Account to keep allowed; exactly 32 bytes. + */accountId: Data, + /** + * Human-readable name used in logs and reports. + */label: String + ) + + + + + +} + +#if compiler(>=6) +extension NativeStatementRenewalTarget: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNativeStatementRenewalTarget: FfiConverterRustBuffer { + typealias SwiftType = NativeStatementRenewalTarget + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NativeStatementRenewalTarget { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .productStatementAllowance(productId: try FfiConverterString.read(from: &buf) + ) + + case 2: return .walletSso + + case 3: return .account(accountId: try FfiConverterData.read(from: &buf), label: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: NativeStatementRenewalTarget, into buf: inout [UInt8]) { + switch value { + + + case let .productStatementAllowance(productId): + writeInt(&buf, Int32(1)) + FfiConverterString.write(productId, into: &buf) + + + case .walletSso: + writeInt(&buf, Int32(2)) + + + case let .account(accountId,label): + writeInt(&buf, Int32(3)) + FfiConverterData.write(accountId, into: &buf) + FfiConverterString.write(label, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNativeStatementRenewalTarget_lift(_ buf: RustBuffer) throws -> NativeStatementRenewalTarget { + return try FfiConverterTypeNativeStatementRenewalTarget.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNativeStatementRenewalTarget_lower(_ value: NativeStatementRenewalTarget) -> RustBuffer { + return FfiConverterTypeNativeStatementRenewalTarget.lower(value) +} + + + /** * How the input URL should be opened. Kept in one enum rather than passing * a raw string so the dispatcher can reject invalid input before reaching @@ -4666,6 +5305,123 @@ public func FfiConverterTypeProductRuntimeError_lower(_ value: ProductRuntimeErr } +/** + * Outcome of renewing one target. + */ + +public enum TargetRenewalStatus: Equatable, Hashable { + + /** + * The extrinsic reached a block; the target holds `seq` this period. + */ + case registered( + /** + * Claimed slot sequence. + */seq: UInt32, + /** + * Block hash the extrinsic landed in. + */blockHash: String + ) + /** + * The target already held a slot this period; nothing submitted. + */ + case alreadyAllocated( + /** + * Existing slot sequence. + */seq: UInt32 + ) + /** + * Registration failed; the target is retried on the next tick. + */ + case failed( + /** + * Failure detail. + */reason: String + ) + /** + * Not attempted: the host ran out of slots earlier in the pass. + */ + case skippedExhausted + + + + + +} + +#if compiler(>=6) +extension TargetRenewalStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTargetRenewalStatus: FfiConverterRustBuffer { + typealias SwiftType = TargetRenewalStatus + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TargetRenewalStatus { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .registered(seq: try FfiConverterUInt32.read(from: &buf), blockHash: try FfiConverterString.read(from: &buf) + ) + + case 2: return .alreadyAllocated(seq: try FfiConverterUInt32.read(from: &buf) + ) + + case 3: return .failed(reason: try FfiConverterString.read(from: &buf) + ) + + case 4: return .skippedExhausted + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: TargetRenewalStatus, into buf: inout [UInt8]) { + switch value { + + + case let .registered(seq,blockHash): + writeInt(&buf, Int32(1)) + FfiConverterUInt32.write(seq, into: &buf) + FfiConverterString.write(blockHash, into: &buf) + + + case let .alreadyAllocated(seq): + writeInt(&buf, Int32(2)) + FfiConverterUInt32.write(seq, into: &buf) + + + case let .failed(reason): + writeInt(&buf, Int32(3)) + FfiConverterString.write(reason, into: &buf) + + + case .skippedExhausted: + writeInt(&buf, Int32(4)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTargetRenewalStatus_lift(_ buf: RustBuffer) throws -> TargetRenewalStatus { + return try FfiConverterTypeTargetRenewalStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTargetRenewalStatus_lower(_ value: TargetRenewalStatus) -> RustBuffer { + return FfiConverterTypeTargetRenewalStatus.lower(value) +} + + + /** * Failure modes returned from host-facing `start_ws_bridge` wrappers. */ @@ -5094,6 +5850,56 @@ fileprivate struct FfiConverterSequenceTypeProductAccountId: FfiConverterRustBuf return seq } } + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeStatementRenewalOutcome: FfiConverterRustBuffer { + typealias SwiftType = [StatementRenewalOutcome] + + public static func write(_ value: [StatementRenewalOutcome], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeStatementRenewalOutcome.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [StatementRenewalOutcome] { + let len: Int32 = try readInt(&buf) + var seq = [StatementRenewalOutcome]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeStatementRenewalOutcome.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeNativeStatementRenewalTarget: FfiConverterRustBuffer { + typealias SwiftType = [NativeStatementRenewalTarget] + + public static func write(_ value: [NativeStatementRenewalTarget], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeNativeStatementRenewalTarget.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [NativeStatementRenewalTarget] { + let len: Int32 = try readInt(&buf) + var seq = [NativeStatementRenewalTarget]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeNativeStatementRenewalTarget.read(from: &buf)) + } + return seq + } +} private let UNIFFI_RUST_FUTURE_POLL_READY: Int8 = 0 private let UNIFFI_RUST_FUTURE_POLL_WAKE: Int8 = 1 @@ -5401,6 +6207,9 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapicore_disconnect() != 18254) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapicore_next_statement_renewal_delay() != 13069) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapicore_notify_chain_closed() != 25320) { return InitializationResult.apiChecksumMismatch } @@ -5419,6 +6228,9 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapicore_permission_authorization_status() != 21962) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapicore_renew_statement_allowances() != 57273) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapicore_ring_vrf_providers() != 44875) { return InitializationResult.apiChecksumMismatch } @@ -5431,18 +6243,27 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapicore_set_permission_authorization_status() != 37317) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapicore_start_statement_allowance_renewal() != 20540) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapicore_start_ws_bridge() != 34234) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_nativetruapicore_stop_ws_bridge() != 13438) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapicore_track_statement_renewal_targets() != 64109) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_activate_local_session() != 40075) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_disconnect() != 38487) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_next_statement_renewal_delay() != 33452) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_notify_chain_closed() != 55360) { return InitializationResult.apiChecksumMismatch } @@ -5452,6 +6273,15 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_open_product_execution() != 49537) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_renew_statement_allowances() != 11225) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_start_statement_allowance_renewal() != 18621) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_track_statement_renewal_targets() != 53330) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativecustomrenderersubscription_cancel() != 26593) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h index 2d0c3b221..0aaa60a14 100644 --- a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +++ b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h @@ -731,6 +731,11 @@ void uniffi_truapi_server_fn_method_nativetruapicore_cancel_login(uint64_t ptr, void uniffi_truapi_server_fn_method_nativetruapicore_disconnect(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_NEXT_STATEMENT_RENEWAL_DELAY +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_NEXT_STATEMENT_RENEWAL_DELAY +RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_next_statement_renewal_delay(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_NOTIFY_CHAIN_CLOSED #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_NOTIFY_CHAIN_CLOSED void uniffi_truapi_server_fn_method_nativetruapicore_notify_chain_closed(uint64_t ptr, uint32_t connection_id, RustCallStatus *_Nonnull out_status @@ -761,6 +766,11 @@ void uniffi_truapi_server_fn_method_nativetruapicore_notify_theme_changed(uint64 RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_permission_authorization_status(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_RENEW_STATEMENT_ALLOWANCES +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_RENEW_STATEMENT_ALLOWANCES +RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_renew_statement_allowances(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_RING_VRF_PROVIDERS #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_RING_VRF_PROVIDERS RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_ring_vrf_providers(uint64_t ptr, RustBuffer ring, RustCallStatus *_Nonnull out_status @@ -781,6 +791,11 @@ RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_selected_ring_vrf_pro void uniffi_truapi_server_fn_method_nativetruapicore_set_permission_authorization_status(uint64_t ptr, RustBuffer request, RustBuffer status, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_START_STATEMENT_ALLOWANCE_RENEWAL +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_START_STATEMENT_ALLOWANCE_RENEWAL +void uniffi_truapi_server_fn_method_nativetruapicore_start_statement_allowance_renewal(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_START_WS_BRIDGE #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_START_WS_BRIDGE RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_start_ws_bridge(uint64_t ptr, uint16_t bind_port, RustCallStatus *_Nonnull out_status @@ -791,6 +806,11 @@ RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_start_ws_bridge(uint6 void uniffi_truapi_server_fn_method_nativetruapicore_stop_ws_bridge(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_TRACK_STATEMENT_RENEWAL_TARGETS +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_TRACK_STATEMENT_RENEWAL_TARGETS +void uniffi_truapi_server_fn_method_nativetruapicore_track_statement_renewal_targets(uint64_t ptr, RustBuffer targets, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_CLONE_NATIVETRUAPIHOSTRUNTIME #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_CLONE_NATIVETRUAPIHOSTRUNTIME uint64_t uniffi_truapi_server_fn_clone_nativetruapihostruntime(uint64_t handle, RustCallStatus *_Nonnull out_status @@ -816,6 +836,11 @@ void uniffi_truapi_server_fn_method_nativetruapihostruntime_activate_local_sessi void uniffi_truapi_server_fn_method_nativetruapihostruntime_disconnect(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_NEXT_STATEMENT_RENEWAL_DELAY +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_NEXT_STATEMENT_RENEWAL_DELAY +RustBuffer uniffi_truapi_server_fn_method_nativetruapihostruntime_next_statement_renewal_delay(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_NOTIFY_CHAIN_CLOSED #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_NOTIFY_CHAIN_CLOSED void uniffi_truapi_server_fn_method_nativetruapihostruntime_notify_chain_closed(uint64_t ptr, uint32_t connection_id, RustCallStatus *_Nonnull out_status @@ -831,6 +856,21 @@ void uniffi_truapi_server_fn_method_nativetruapihostruntime_notify_chain_respons uint64_t uniffi_truapi_server_fn_method_nativetruapihostruntime_open_product_execution(uint64_t ptr, uint64_t callbacks, RustBuffer chat_callbacks, RustBuffer execution_config, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_RENEW_STATEMENT_ALLOWANCES +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_RENEW_STATEMENT_ALLOWANCES +RustBuffer uniffi_truapi_server_fn_method_nativetruapihostruntime_renew_statement_allowances(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_START_STATEMENT_ALLOWANCE_RENEWAL +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_START_STATEMENT_ALLOWANCE_RENEWAL +void uniffi_truapi_server_fn_method_nativetruapihostruntime_start_statement_allowance_renewal(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_TRACK_STATEMENT_RENEWAL_TARGETS +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_TRACK_STATEMENT_RENEWAL_TARGETS +void uniffi_truapi_server_fn_method_nativetruapihostruntime_track_statement_renewal_targets(uint64_t ptr, RustBuffer targets, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_CLONE_NATIVECUSTOMRENDERERSUBSCRIPTION #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_CLONE_NATIVECUSTOMRENDERERSUBSCRIPTION uint64_t uniffi_truapi_server_fn_clone_nativecustomrenderersubscription(uint64_t handle, RustCallStatus *_Nonnull out_status @@ -1371,6 +1411,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_cancel_login(void #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_DISCONNECT uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_NEXT_STATEMENT_RENEWAL_DELAY +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_NEXT_STATEMENT_RENEWAL_DELAY +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_next_statement_renewal_delay(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_NOTIFY_CHAIN_CLOSED @@ -1407,6 +1453,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_notify_theme_chan #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_PERMISSION_AUTHORIZATION_STATUS uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_permission_authorization_status(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_RENEW_STATEMENT_ALLOWANCES +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_RENEW_STATEMENT_ALLOWANCES +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_renew_statement_allowances(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_RING_VRF_PROVIDERS @@ -1431,6 +1483,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_selected_ring_vrf #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_SET_PERMISSION_AUTHORIZATION_STATUS uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_set_permission_authorization_status(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_START_STATEMENT_ALLOWANCE_RENEWAL +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_START_STATEMENT_ALLOWANCE_RENEWAL +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_start_statement_allowance_renewal(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_START_WS_BRIDGE @@ -1443,6 +1501,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_start_ws_bridge(v #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_STOP_WS_BRIDGE uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_stop_ws_bridge(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_TRACK_STATEMENT_RENEWAL_TARGETS +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_TRACK_STATEMENT_RENEWAL_TARGETS +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_track_statement_renewal_targets(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_ACTIVATE_LOCAL_SESSION @@ -1455,6 +1519,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_activate_l #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_DISCONNECT uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_NEXT_STATEMENT_RENEWAL_DELAY +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_NEXT_STATEMENT_RENEWAL_DELAY +uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_next_statement_renewal_delay(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_NOTIFY_CHAIN_CLOSED @@ -1473,6 +1543,24 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_notify_cha #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_OPEN_PRODUCT_EXECUTION uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_open_product_execution(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_RENEW_STATEMENT_ALLOWANCES +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_RENEW_STATEMENT_ALLOWANCES +uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_renew_statement_allowances(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_START_STATEMENT_ALLOWANCE_RENEWAL +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_START_STATEMENT_ALLOWANCE_RENEWAL +uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_start_statement_allowance_renewal(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_TRACK_STATEMENT_RENEWAL_TARGETS +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_TRACK_STATEMENT_RENEWAL_TARGETS +uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_track_statement_renewal_targets(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECUSTOMRENDERERSUBSCRIPTION_CANCEL diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 11f38cdaf..e99e94c55 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -1383,8 +1383,9 @@ async fn run_renew(session: &mut SigningHostSession) -> Result<()> { .map_err(|err| anyhow::anyhow!("allowance renewal failed: {}", err.reason))?; let (mut renewed, mut fresh, mut failed, mut skipped) = (0usize, 0usize, 0usize, 0usize); - for (target, status) in &report.outcomes { - match status { + for outcome in &report.outcomes { + let target = &outcome.label; + match &outcome.status { TargetRenewalStatus::Registered { seq, block_hash } => { renewed += 1; terminal_ui::output_event(SystemEvent::AllowanceReady { diff --git a/rust/crates/truapi-host-cli/tests/live_people_chain.rs b/rust/crates/truapi-host-cli/tests/live_people_chain.rs index 7206ad50b..4df3832bb 100644 --- a/rust/crates/truapi-host-cli/tests/live_people_chain.rs +++ b/rust/crates/truapi-host-cli/tests/live_people_chain.rs @@ -187,3 +187,28 @@ async fn live_metadata_still_exposes_the_allowance_extension_shape() { chain.state.spec_version, ); } + +/// The renewal docs tell hosts one scheduled pass per period is enough because +/// an ended period's allowances stay active for `Resources.StmtStoreGraceWindow`. +/// That number is quoted in four places and read by no code, so this is what +/// notices if the runtime shrinks it and the guidance stops being true. +#[tokio::test] +#[ignore = "needs network access to a live People chain"] +async fn live_grace_window_still_leaves_a_full_period_of_slack() { + let rpc = connect().await; + let metadata = alloc::fetch_metadata(&rpc) + .await + .expect("live People metadata"); + let grace = metadata + .constant_u32("Resources", "StmtStoreGraceWindow") + .expect("the runtime declares a statement-store grace window"); + let period = alloc::slot::STATEMENT_STORE_PERIOD_SECONDS; + + assert!( + u64::from(grace) >= period, + "grace window is {grace}s, under one {period}s period: a host waking once \ + per period can now miss it, so the scheduling guidance in the host \ + READMEs and on next_statement_renewal_delay needs revisiting" + ); + println!("live StmtStoreGraceWindow={grace}s"); +} diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 0e515d200..41fd9865b 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -614,6 +614,79 @@ impl NativeTrUApiHostRuntime { } } +/// An account the host wants kept allowed on the Statement Store across +/// periods. Mirrors [`crate::runtime::StatementRenewalTarget`] with a +/// length-checked `account_id`, because UniFFI carries byte arrays as `Vec` +/// rather than a fixed width. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum NativeStatementRenewalTarget { + /// The statement-store allowance account derived for one product. + ProductStatementAllowance { + /// Product the allowance account belongs to. + product_id: String, + }, + /// The wallet's own SSO account. + WalletSso, + /// A fixed account, such as a pairing peer's device statement key. + Account { + /// Account to keep allowed; exactly 32 bytes. + account_id: Vec, + /// Human-readable name used in logs and reports. + label: String, + }, +} + +/// Rejected renewal-target registration. +#[derive(Debug, Clone, thiserror::Error, uniffi::Error)] +pub enum NativeRenewalTargetError { + /// `account_id` was not exactly 32 bytes. + #[error("account_id must be exactly 32 bytes, got {actual}")] + InvalidAccountId { + /// Supplied byte length. + actual: u64, + }, + /// `product_id` is not a usable product identifier. + #[error("product_id {product_id} is not a valid product identifier")] + InvalidProductId { + /// The identifier as supplied. + product_id: String, + }, + /// The core refused to record the targets. + #[error("{reason}")] + Rejected { + /// Human-readable rejection reason. + reason: String, + }, +} + +impl TryFrom for crate::runtime::StatementRenewalTarget { + type Error = NativeRenewalTargetError; + + fn try_from(target: NativeStatementRenewalTarget) -> Result { + Ok(match target { + NativeStatementRenewalTarget::ProductStatementAllowance { product_id } => { + // The renewal account is derived from this string, and a product + // connection derives its own from the normalized form. Skipping + // the normalization here renews an account no product uses, and + // the real one lapses at the next boundary. + Self::ProductStatementAllowance { + product_id: normalize_product_identifier(&product_id) + .map_err(|_| NativeRenewalTargetError::InvalidProductId { product_id })?, + } + } + NativeStatementRenewalTarget::WalletSso => Self::WalletSso, + NativeStatementRenewalTarget::Account { account_id, label } => { + let account_id: [u8; 32] = account_id.as_slice().try_into().map_err(|_| { + NativeRenewalTargetError::InvalidAccountId { + actual: account_id.len() as u64, + } + })?; + Self::Account { account_id, label } + } + }) + } +} + #[uniffi::export] impl NativeTrUApiHostRuntime { /// Construct one host-level runtime and optionally activate its local session. @@ -649,6 +722,63 @@ impl NativeTrUApiHostRuntime { futures::executor::block_on(self.runtime.disconnect_session()); } + /// Record the accounts a renewal pass should keep allowed. The ledger + /// persists, so this only has to be called when the set changes, not on + /// every launch. Renewal has nothing to do until at least one target is + /// tracked. + pub fn track_statement_renewal_targets( + &self, + targets: Vec, + ) -> Result<(), NativeRenewalTargetError> { + let targets = targets + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?; + futures::executor::block_on(self.runtime.track_statement_renewal_targets(targets)) + .map_err(|err| NativeRenewalTargetError::Rejected { reason: err.reason }) + } + + /// Run one renewal pass now and report what each tracked target got. + /// + /// This is the entry point for hosts whose process cannot stay alive + /// between periods: drive it from WorkManager or BGTaskScheduler rather + /// than [`Self::start_statement_allowance_renewal`]. It submits extrinsics + /// and blocks until they are included, so call it from a background thread. + /// + /// Needs an active session, which is the whole difficulty of the scheduled + /// case: an OS-woken cold start has none until the host restores one, and + /// the pass then fails with the bare reason `Disconnected`. Restore the + /// session before calling, and treat that reason as "not ready" rather than + /// as a renewal failure. [`Self::start_statement_allowance_renewal`] does + /// not need this care; its loop skips a tick with no session and retries. + pub fn renew_statement_allowances( + &self, + ) -> Result { + futures::executor::block_on(self.runtime.renew_statement_allowances()) + .map_err(HostRejection::from) + } + + /// Start the in-process renewal loop, for hosts that stay resident. Mobile + /// hosts should schedule [`Self::renew_statement_allowances`] instead, + /// because a suspended process stops ticking. Idempotent; the loop ends + /// when this runtime is dropped. + pub fn start_statement_allowance_renewal(&self) { + self.runtime.start_statement_allowance_renewal(); + } + + /// The in-process loop's own cadence: at most an hour, tightening to land + /// just after the next period boundary. + /// + /// The hourly cap is a retry rhythm, not a statement about when work is + /// due. An allowance stays usable for `Resources.StmtStoreGraceWindow` past + /// its boundary, 48 hours on `paseo-next-v2`, so a host scheduling one OS + /// wake-up per period has ample slack and should treat any value under an + /// hour as the boundary approaching, rather than requesting a wake every + /// hour for a pass that will almost always report `AlreadyAllocated`. + pub fn next_statement_renewal_delay(&self) -> std::time::Duration { + self.runtime.next_statement_renewal_delay() + } + /// Activate or replace the process-wide local signing session. pub fn activate_local_session( &self, @@ -1000,6 +1130,59 @@ impl NativeTrUApiCore { self.host.activate_local_session(secret, lite_username) } + /// Record the accounts renewal should keep allowed. The ledger persists, so + /// this only has to be called when the set changes, not on every launch. + /// Renewal has nothing to do until at least one target is tracked. + /// + /// Needs an active session, so call it after + /// [`Self::activate_local_session`] or after pairing, not at construction. + /// + /// The ledger is append-only. There is no untrack, and an entry is dropped + /// only when the identity that promised it changes, which keeps derivation + /// recipes and discards raw account ids. Re-tracking is idempotent, so + /// re-track the full set after an identity change. + pub fn track_statement_renewal_targets( + &self, + targets: Vec, + ) -> Result<(), NativeRenewalTargetError> { + self.host.track_statement_renewal_targets(targets) + } + + /// Run one renewal pass now and report what each tracked target got. + /// + /// For hosts whose process cannot stay alive between periods: drive it from + /// WorkManager or BGTaskScheduler rather than + /// [`Self::start_statement_allowance_renewal`]. It submits extrinsics and + /// blocks until they are included, so call it from a background thread. + /// + /// Needs an active session, which is the whole difficulty of the scheduled + /// case: an OS-woken cold start has none until the host restores one, and + /// the pass then fails with the bare reason `Disconnected`. Restore the + /// session before calling, and treat that reason as "not ready" rather than + /// as a renewal failure. + pub fn renew_statement_allowances( + &self, + ) -> Result { + self.host.renew_statement_allowances() + } + + /// Start the in-process renewal loop, for a host that stays resident. A + /// suspended app stops ticking, so prefer scheduling + /// [`Self::renew_statement_allowances`]. Idempotent, and unlike the one-shot + /// call it tolerates having no session: a tick without one is skipped and + /// retried. + pub fn start_statement_allowance_renewal(&self) { + self.host.start_statement_allowance_renewal(); + } + + /// The in-process loop's own cadence, capped at an hour. An allowance stays + /// usable for `Resources.StmtStoreGraceWindow` past its boundary, 48 hours + /// on `paseo-next-v2`, so a host scheduling one wake-up per period has ample + /// slack and should read a value under an hour as the boundary approaching. + pub fn next_statement_renewal_delay(&self) -> std::time::Duration { + self.host.next_statement_renewal_delay() + } + /// List registered providers for a ring so host UI can present the RFC-0024 /// personhood-provider setting. pub fn ring_vrf_providers( @@ -1610,6 +1793,89 @@ mod tests { type PreimageFixtureEntries = Vec<(Vec, Option>)>; + /// UniFFI hands `account_id` over as a length-free `Vec`, so the width + /// the ledger depends on is only enforced here. A short id that converted + /// anyway would renew an allowance for the wrong account. + #[test] + fn a_renewal_target_account_id_must_be_exactly_32_bytes() { + let target = |len: usize| NativeStatementRenewalTarget::Account { + account_id: vec![0x11; len], + label: "device".to_string(), + }; + + assert!(matches!( + crate::runtime::StatementRenewalTarget::try_from(target(32)), + Ok(crate::runtime::StatementRenewalTarget::Account { account_id, .. }) + if account_id == [0x11; 32] + )); + for len in [0, 31, 33] { + assert!( + matches!( + crate::runtime::StatementRenewalTarget::try_from(target(len)), + Err(NativeRenewalTargetError::InvalidAccountId { actual }) if actual == len as u64 + ), + "a {len}-byte account id must be rejected, and report its length" + ); + } + } + + /// The renewal account is derived from `product_id`, and a product + /// connection derives its own from the normalized form, so an unnormalized + /// id here renews an account no product uses while the real one lapses. + #[test] + fn a_product_target_normalizes_its_identifier() { + for supplied in [ + " truapi-playground.dot ", + "TruAPI-Playground.dot", + "TRUAPI-PLAYGROUND.DOT", + ] { + let converted = crate::runtime::StatementRenewalTarget::try_from( + NativeStatementRenewalTarget::ProductStatementAllowance { + product_id: supplied.to_string(), + }, + ); + assert!( + matches!( + converted, + Ok(crate::runtime::StatementRenewalTarget::ProductStatementAllowance { + ref product_id + }) if product_id == "truapi-playground.dot" + ), + "{supplied:?} did not normalize: {converted:?}" + ); + } + + assert!(matches!( + crate::runtime::StatementRenewalTarget::try_from( + NativeStatementRenewalTarget::ProductStatementAllowance { + product_id: "not a product".to_string(), + } + ), + Err(NativeRenewalTargetError::InvalidProductId { .. }) + )); + } + + /// The other two variants carry no bytes to validate, so they must convert + /// rather than share the `Account` arm's failure path. + #[test] + fn byteless_renewal_targets_convert() { + assert!(matches!( + crate::runtime::StatementRenewalTarget::try_from( + NativeStatementRenewalTarget::WalletSso + ), + Ok(crate::runtime::StatementRenewalTarget::WalletSso) + )); + assert!(matches!( + crate::runtime::StatementRenewalTarget::try_from( + NativeStatementRenewalTarget::ProductStatementAllowance { + product_id: "truapi-playground.dot".to_string(), + } + ), + Ok(crate::runtime::StatementRenewalTarget::ProductStatementAllowance { product_id }) + if product_id == "truapi-playground.dot" + )); + } + fn text_chat_action(text: &str) -> v01::HostChatActionSubscribeItem { v01::HostChatActionSubscribeItem { room_id: "room".to_string(), diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs index d98496151..a3314a3f2 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -1,8 +1,11 @@ //! Proactive renewal of statement-store allowances across period boundaries. //! -//! Allowances are claimed per UTC-day period and die at the boundary, so a -//! long-lived host must re-register every account it promised to keep allowed -//! (RFC-0010 assigns renewal to the Account Holder). This module is the +//! Allowances are claimed per UTC-day period and stop being renewed at the +//! boundary, so a long-lived host must re-register every account it promised to +//! keep allowed (RFC-0010 assigns renewal to the Account Holder). They are not +//! revoked the instant the period ends: `Resources.StmtStoreGraceWindow` keeps +//! an ended period's allowances active until cleanup catches up, 172800 seconds +//! on `paseo-next-v2` as of 2026-08-15. This module is the //! chain-pure pass: given already-resolved targets, register each for the //! requested period. Scheduling and target persistence live in //! `signing_host::allowance_renewal`. @@ -20,8 +23,13 @@ use super::{ RegistrationOutcome, RegistrationParams, StatementAllowanceError, register_statement_account, }; -/// Cap between renewal ticks, mirroring the on-chain grace period after a -/// period boundary. +/// Cap between renewal ticks for the in-process loop. +/// +/// A retry rhythm, not a deadline. An allowance stays usable for +/// `Resources.StmtStoreGraceWindow` past its period boundary, which is 48 hours +/// on `paseo-next-v2`, so a pass has ample slack and this only decides how +/// promptly a transient failure is retried. A host scheduling its own wake-ups +/// does not need this cadence; one pass per period is enough. const MAX_TICK_INTERVAL: Duration = Duration::from_secs(3_600); /// Margin after a period boundary before the boundary tick fires, so the /// chain has rotated to the new period by the time we scan slots. @@ -60,6 +68,7 @@ pub struct ResolvedRenewalTarget { /// Outcome of renewing one target. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] pub enum TargetRenewalStatus { /// The extrinsic reached a block; the target holds `seq` this period. Registered { @@ -82,13 +91,25 @@ pub enum TargetRenewalStatus { SkippedExhausted, } +/// What one target's renewal produced, paired with the label that identifies it +/// in the ledger. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct StatementRenewalOutcome { + /// Ledger label for the renewed target. + pub label: String, + /// What the pass did for this target. + pub status: TargetRenewalStatus, +} + /// Summary of one renewal pass. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] pub struct StatementRenewalReport { /// Period the pass registered for. pub period: u32, - /// Per-target `(label, status)` in ledger order. - pub outcomes: Vec<(String, TargetRenewalStatus)>, + /// Per-target outcomes in ledger order. + pub outcomes: Vec, /// Whether the pass hit slot exhaustion for this period. pub slots_exhausted: bool, } @@ -164,7 +185,8 @@ pub async fn renew_targets( } /// Delay until the next renewal tick: hourly, but always shortly after each -/// period boundary so expired allowances are refreshed within the grace window. +/// period boundary rather than before it. The margin is about the chain's clock, +/// not urgency; see the inline note below. pub fn next_tick_delay(now_seconds: u64) -> Duration { let next_boundary = (now_seconds / STATEMENT_STORE_PERIOD_SECONDS + 1) * STATEMENT_STORE_PERIOD_SECONDS; @@ -229,7 +251,10 @@ fn fold_outcomes( } None => TargetRenewalStatus::SkippedExhausted, }; - (target.label.clone(), status) + StatementRenewalOutcome { + label: target.label.clone(), + status, + } }) .collect(); StatementRenewalReport { @@ -325,9 +350,9 @@ mod tests { let seqs: Vec = report .outcomes .iter() - .filter_map(|(_, status)| match status { + .filter_map(|outcome| match outcome.status { TargetRenewalStatus::Registered { seq, .. } - | TargetRenewalStatus::AlreadyAllocated { seq } => Some(*seq), + | TargetRenewalStatus::AlreadyAllocated { seq } => Some(seq), _ => None, }) .collect(); @@ -361,6 +386,13 @@ mod tests { } } + fn outcome(label: &str, status: TargetRenewalStatus) -> StatementRenewalOutcome { + StatementRenewalOutcome { + label: label.to_string(), + status, + } + } + #[test] fn tick_delay_caps_at_one_hour_mid_day() { let mid_day = 86_400 * 20_000 + 43_200; @@ -429,18 +461,15 @@ mod tests { StatementRenewalReport { period: 7, outcomes: vec![ - ( - "a".to_string(), - TargetRenewalStatus::AlreadyAllocated { seq: 1 } - ), - ( - "b".to_string(), + outcome("a", TargetRenewalStatus::AlreadyAllocated { seq: 1 }), + outcome( + "b", TargetRenewalStatus::Failed { reason: "rpc timeout".to_string() } ), - ( - "c".to_string(), + outcome( + "c", TargetRenewalStatus::Registered { seq: 2, block_hash: "0xabc".to_string() @@ -468,17 +497,14 @@ mod tests { StatementRenewalReport { period: 7, outcomes: vec![ - ( - "a".to_string(), - TargetRenewalStatus::AlreadyAllocated { seq: 0 } - ), - ( - "b".to_string(), + outcome("a", TargetRenewalStatus::AlreadyAllocated { seq: 0 }), + outcome( + "b", TargetRenewalStatus::Failed { reason: exhausted_failure().reason } ), - ("c".to_string(), TargetRenewalStatus::SkippedExhausted), + outcome("c", TargetRenewalStatus::SkippedExhausted), ], slots_exhausted: true, }