From 579a5b26a9ba4a0974d812b44d32c6ea0708e663 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Sat, 15 Aug 2026 12:39:14 -0400 Subject: [PATCH 1/6] feat(server): export statement-store allowance renewal to native hosts The renewal engine landed with #308 but stopped at SigningHostRuntime, so iOS and Android could not run a pass at all: the entry points were never on the UniFFI surface and native.rs did not mention renewal. Exports the four calls a host needs to own the schedule while the core owns the ledger and the registration. StatementRenewalReport carried `Vec<(String, TargetRenewalStatus)>` and UniFFI has no tuple type, so the pair becomes a named StatementRenewalOutcome. That also names the label at each use site instead of leaving it positional. StatementRenewalTarget cannot cross as-is either, because its account id is a [u8; 32] and UniFFI carries bytes as Vec. NativeStatementRenewalTarget mirrors it with a length check, following the genesis-hash validation the native config types already use; a short id that converted anyway would renew an allowance for the wrong account. Kotlin bindings are generated on demand and need no update. The committed Swift bindings are regenerated here, so `sync-bindings.sh --check` stays green. --- ios/truapi-host/README.md | 30 + .../Sources/TrUAPIHost/truapi_server.swift | 649 ++++++++++++++++++ .../include/truapi_serverFFI.h | 44 ++ rust/crates/truapi-host-cli/src/main.rs | 5 +- rust/crates/truapi-server/src/native.rs | 149 ++++ .../runtime/statement_allowance/renewal.rs | 57 +- 6 files changed, 912 insertions(+), 22 deletions(-) diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index b1071ae2b..e34befe6e 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -96,6 +96,36 @@ 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 and lapse when the period rolls over, so a host has to re-register the accounts it wants to keep writing. The runtime owns the ledger and the registration; the app owns only the schedule. + +Record the accounts once. The ledger persists, so this is needed when the set changes, not on every launch: + +```swift +try runtime.trackStatementRenewalTargets(targets: [ + .walletSso, + .account(accountId: deviceStatementKey, label: "device"), +]) +``` + +Then run a pass from a background task. `renewStatementAllowances()` submits extrinsics and blocks until they are included, so keep it off the main thread, and use `nextStatementRenewalDelay()` to schedule the next wake-up: + +```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. +} +scheduleNextRun(after: runtime.nextStatementRenewalDelay()) +``` + +`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. + +An account id must be exactly 32 bytes. Anything else is rejected as `NativeRenewalTargetError.invalidAccountId`. + ## Example > **Threading:** the Rust core invokes every `HostCallbacks` method on a diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 9bef8fdc8..043b7d4c6 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) + } +} + @@ -3195,6 +3223,11 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { */ func disconnect() + /** + * How long until the next pass is due, for scheduling an OS wake-up. + */ + func nextStatementRenewalDelay() -> TimeInterval + /** * Notify the shared chain adapter that a connection closed. */ @@ -3212,6 +3245,32 @@ 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. + */ + 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. @@ -3304,6 +3363,18 @@ open func disconnect() {try! rustCall() { self.uniffiCloneHandle(),uniffiCallStatus ) } +} + + /** + * How long until the next pass is due, for scheduling an OS wake-up. + */ +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 + ) +}) } /** @@ -3348,6 +3419,52 @@ 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. + */ +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 + ) +} +} + } @@ -3773,6 +3890,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=`). @@ -4191,6 +4457,106 @@ 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 + ) + /** + * 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 .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 .Rejected(reason): + writeInt(&buf, Int32(2)) + 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. */ @@ -4387,6 +4753,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 @@ -4658,6 +5128,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. */ @@ -5086,6 +5673,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 @@ -5435,6 +6072,9 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_disconnect() != 38487) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_next_statement_renewal_delay() != 17292) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_notify_chain_closed() != 55360) { return InitializationResult.apiChecksumMismatch } @@ -5444,6 +6084,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() != 29034) { + 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..5a2bcc64e 100644 --- a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +++ b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h @@ -816,6 +816,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 +836,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 @@ -1455,6 +1475,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 +1499,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-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 99623ce7b..3b4221567 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -610,6 +610,66 @@ 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, + }, + /// 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 } => { + Self::ProductStatementAllowance { 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. @@ -645,6 +705,48 @@ 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. + 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(); + } + + /// How long until the next pass is due, for scheduling an OS wake-up. + 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, @@ -1603,6 +1705,53 @@ 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 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..ad83fa02a 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -60,6 +60,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 +83,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, } @@ -229,7 +242,10 @@ fn fold_outcomes( } None => TargetRenewalStatus::SkippedExhausted, }; - (target.label.clone(), status) + StatementRenewalOutcome { + label: target.label.clone(), + status, + } }) .collect(); StatementRenewalReport { @@ -325,9 +341,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 +377,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 +452,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 +488,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, } From 5244b9acc2187232c4102f723d2306a4673f277b Mon Sep 17 00:00:00 2001 From: tarikgul Date: Sat, 15 Aug 2026 12:56:10 -0400 Subject: [PATCH 2/6] feat(host): reach renewal from the Swift and Kotlin host shells Exporting the entry points over UniFFI left them unreachable from anything an app actually holds: TrUAPIHostRuntime keeps its NativeTrUApiHostRuntime private and forwarded five methods, and both TrUAPIHostCore shells wrap NativeTrUApiCore, which had no renewal surface and no accessor for the runtime it owns. An app following the README would not have compiled. NativeTrUApiCore now delegates the four calls, and both host shells forward them. Swift gets a StatementRenewalTarget enum so the public API does not carry the Native-prefixed generated type. Corrects what the READMEs promised. The ledger is append-only, so "call it when the set changes" was wrong: a target can only leave by identity rotation, which silently drops raw accounts while leaving derivation recipes intact, and a dropped target is absent from the report rather than reported as failed. Tracking also needs an active session, a pass has no cancellation against a background budget, and the delay is the in-process loop's hourly retry cadence rather than a once-per-period schedule. --- android/truapi-host/README.md | 33 +++++++ .../kotlin/io/parity/truapi/TrUAPIHost.kt | 47 +++++++++ ios/truapi-host/README.md | 17 ++-- .../Sources/TrUAPIHost/TrUAPIHost.swift | 83 ++++++++++++++++ .../Sources/TrUAPIHost/truapi_server.swift | 99 ++++++++++++++++++- .../include/truapi_serverFFI.h | 44 +++++++++ rust/crates/truapi-server/src/native.rs | 34 ++++++- 7 files changed, 347 insertions(+), 10 deletions(-) diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 7cfd51b69..e2124d41e 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 and lapse when the period rolls over, so a host has to re-register the accounts it wants to keep writing. 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. + +Then run a pass from a `WorkManager` worker. It submits extrinsics and blocks until they are included, so keep it off the main thread: + +```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. +} +``` + +Allowances lapse only at a period boundary, so one scheduled pass per period is enough. `nextStatementRenewalDelay()` reports the in-process loop's 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 90f2de663..2a838a963 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 @@ -41,8 +41,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 @@ -586,6 +589,50 @@ 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 + * lapse at a period boundary, 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 e34befe6e..0c9a2be35 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -100,16 +100,18 @@ Both return a `Bool` granted flag; the host renders the typed request in its own Statement-store allowances are granted per period and lapse when the period rolls over, so a host has to re-register the accounts it wants to keep writing. The runtime owns the ledger and the registration; the app owns only the schedule. -Record the accounts once. The ledger persists, so this is needed when the set changes, not on every launch: +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(targets: [ +try runtime.trackStatementRenewalTargets([ .walletSso, .account(accountId: deviceStatementKey, label: "device"), ]) ``` -Then run a pass from a background task. `renewStatementAllowances()` submits extrinsics and blocks until they are included, so keep it off the main thread, and use `nextStatementRenewalDelay()` to schedule the next wake-up: +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. + +Then run a pass from a background task, off the main thread: ```swift let report = try runtime.renewStatementAllowances() @@ -119,12 +121,15 @@ for outcome in report.outcomes { if report.slotsExhausted { // Every slot for this period is taken and none was replaceable. } -scheduleNextRun(after: runtime.nextStatementRenewalDelay()) ``` -`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. +Allowances lapse only at a period boundary, so one scheduled pass per period is enough. `nextStatementRenewalDelay()` reports the in-process loop's cadence, which is 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. -An account id must be exactly 32 bytes. Anything else is rejected as `NativeRenewalTargetError.invalidAccountId`. +`TrUAPIHostCore` exposes the same four calls for hosts that use it instead of `TrUAPIHostRuntime`. ## Example diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 9b47dc903..74e488294 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -741,6 +741,64 @@ 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. + /// + /// 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 + /// lapse at a period boundary, 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. @@ -928,6 +986,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 043b7d4c6..1adca41c1 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -2783,6 +2783,11 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func disconnect() + /** + * See [`NativeTrUApiHostRuntime::next_statement_renewal_delay`]. + */ + func nextStatementRenewalDelay() -> TimeInterval + /** * Notify the core that a native chain connection closed externally. */ @@ -2824,6 +2829,11 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func permissionAuthorizationStatus(request: PermissionAuthorizationRequest) throws -> PermissionAuthorizationStatus + /** + * See [`NativeTrUApiHostRuntime::renew_statement_allowances`]. + */ + func renewStatementAllowances() throws -> StatementRenewalReport + /** * List registered providers for a ring so host UI can present the RFC-0024 * personhood-provider setting. @@ -2851,6 +2861,11 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func setPermissionAuthorizationStatus(request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus) throws + /** + * See [`NativeTrUApiHostRuntime::start_statement_allowance_renewal`]. + */ + func startStatementAllowanceRenewal() + /** * Start the localhost WebSocket bridge. Returns the descriptor the * host hands to the product so it can dial back in. @@ -2862,6 +2877,11 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func stopWsBridge() + /** + * See [`NativeTrUApiHostRuntime::track_statement_renewal_targets`]. + */ + func trackStatementRenewalTargets(targets: [NativeStatementRenewalTarget]) throws + } /** * Legacy single-execution UniFFI object retained for existing embedders. @@ -2987,6 +3007,18 @@ open func disconnect() {try! rustCall() { self.uniffiCloneHandle(),uniffiCallStatus ) } +} + + /** + * See [`NativeTrUApiHostRuntime::next_statement_renewal_delay`]. + */ +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 + ) +}) } /** @@ -3072,6 +3104,18 @@ open func permissionAuthorizationStatus(request: PermissionAuthorizationRequest) FfiConverterTypePermissionAuthorizationRequest_lower(request),uniffiCallStatus ) }) +} + + /** + * See [`NativeTrUApiHostRuntime::renew_statement_allowances`]. + */ +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 + ) +}) } /** @@ -3131,6 +3175,17 @@ open func setPermissionAuthorizationStatus(request: PermissionAuthorizationReque FfiConverterTypePermissionAuthorizationStatus_lower(status),uniffiCallStatus ) } +} + + /** + * See [`NativeTrUApiHostRuntime::start_statement_allowance_renewal`]. + */ +open func startStatementAllowanceRenewal() {try! rustCall() { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_start_statement_allowance_renewal( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} } /** @@ -3158,6 +3213,18 @@ open func stopWsBridge() {try! rustCall() { } } + /** + * See [`NativeTrUApiHostRuntime::track_statement_renewal_targets`]. + */ +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 + ) +} +} + } @@ -3224,7 +3291,14 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { func disconnect() /** - * How long until the next pass is due, for scheduling an OS wake-up. + * 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; allowances only lapse at the boundary. A host scheduling one OS + * wake-up per period should treat any value under an hour as the boundary + * approaching and ignore the rest, rather than requesting a wake every + * hour for a pass that will almost always report `AlreadyAllocated`. */ func nextStatementRenewalDelay() -> TimeInterval @@ -3366,7 +3440,14 @@ open func disconnect() {try! rustCall() { } /** - * How long until the next pass is due, for scheduling an OS wake-up. + * 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; allowances only lapse at the boundary. A host scheduling one OS + * wake-up per period should treat any value under an hour as the boundary + * approaching and ignore the rest, 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() { @@ -6030,6 +6111,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() != 22349) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapicore_notify_chain_closed() != 25320) { return InitializationResult.apiChecksumMismatch } @@ -6048,6 +6132,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() != 16355) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapicore_ring_vrf_providers() != 44875) { return InitializationResult.apiChecksumMismatch } @@ -6060,19 +6147,25 @@ 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() != 42790) { + 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() != 61871) { + 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() != 17292) { + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_next_statement_renewal_delay() != 2618) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_notify_chain_closed() != 55360) { 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 5a2bcc64e..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 @@ -1391,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 @@ -1427,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 @@ -1451,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 @@ -1463,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 diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 3b4221567..3ed4c0bd6 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -742,7 +742,14 @@ impl NativeTrUApiHostRuntime { self.runtime.start_statement_allowance_renewal(); } - /// How long until the next pass is due, for scheduling an OS wake-up. + /// 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; allowances only lapse at the boundary. A host scheduling one OS + /// wake-up per period should treat any value under an hour as the boundary + /// approaching and ignore the rest, 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() } @@ -1098,6 +1105,31 @@ impl NativeTrUApiCore { self.host.activate_local_session(secret, lite_username) } + /// See [`NativeTrUApiHostRuntime::track_statement_renewal_targets`]. + pub fn track_statement_renewal_targets( + &self, + targets: Vec, + ) -> Result<(), NativeRenewalTargetError> { + self.host.track_statement_renewal_targets(targets) + } + + /// See [`NativeTrUApiHostRuntime::renew_statement_allowances`]. + pub fn renew_statement_allowances( + &self, + ) -> Result { + self.host.renew_statement_allowances() + } + + /// See [`NativeTrUApiHostRuntime::start_statement_allowance_renewal`]. + pub fn start_statement_allowance_renewal(&self) { + self.host.start_statement_allowance_renewal(); + } + + /// See [`NativeTrUApiHostRuntime::next_statement_renewal_delay`]. + 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( From a2dde1a15cfe0caf90b097def8e342c4ef48a11e Mon Sep 17 00:00:00 2001 From: tarikgul Date: Sat, 15 Aug 2026 16:18:33 -0400 Subject: [PATCH 3/6] docs(server): state the real statement-store grace window MAX_TICK_INTERVAL claimed to mirror the on-chain grace period after a period boundary. The runtime declares that period as Resources.StmtStoreGraceWindow, which reads 172800 on paseo-next-v2, so the comment named a constant it never reads and was out by 48x. The hourly cap is a retry rhythm for the in-process loop, not a deadline. An allowance stays usable well into the following period, so a host scheduling one pass per period has two days of slack and a missed wake-up is recoverable. Says that where it affects a decision: the constant, the exported delay accessor, and the scheduling guidance in both host READMEs. --- android/truapi-host/README.md | 2 +- ios/truapi-host/README.md | 2 +- .../Sources/TrUAPIHost/truapi_server.swift | 16 +++++++++------- rust/crates/truapi-server/src/native.rs | 7 ++++--- .../src/runtime/statement_allowance/renewal.rs | 9 +++++++-- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index e2124d41e..6c960152b 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -101,7 +101,7 @@ if (report.slotsExhausted) { } ``` -Allowances lapse only at a period boundary, so one scheduled pass per period is enough. `nextStatementRenewalDelay()` reports the in-process loop's 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. +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. diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 0c9a2be35..d7be22b4c 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -123,7 +123,7 @@ if report.slotsExhausted { } ``` -Allowances lapse only at a period boundary, so one scheduled pass per period is enough. `nextStatementRenewalDelay()` reports the in-process loop's cadence, which is 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`. +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. diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 1adca41c1..0a93d2a5e 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -3295,9 +3295,10 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { * just after the next period boundary. * * The hourly cap is a retry rhythm, not a statement about when work is - * due; allowances only lapse at the boundary. A host scheduling one OS - * wake-up per period should treat any value under an hour as the boundary - * approaching and ignore the rest, rather than requesting a wake every + * 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 @@ -3444,9 +3445,10 @@ open func disconnect() {try! rustCall() { * just after the next period boundary. * * The hourly cap is a retry rhythm, not a statement about when work is - * due; allowances only lapse at the boundary. A host scheduling one OS - * wake-up per period should treat any value under an hour as the boundary - * approaching and ignore the rest, rather than requesting a wake every + * 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 { @@ -6165,7 +6167,7 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_disconnect() != 38487) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_next_statement_renewal_delay() != 2618) { + 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) { diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 3ed4c0bd6..ef9c4c1da 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -746,9 +746,10 @@ impl NativeTrUApiHostRuntime { /// just after the next period boundary. /// /// The hourly cap is a retry rhythm, not a statement about when work is - /// due; allowances only lapse at the boundary. A host scheduling one OS - /// wake-up per period should treat any value under an hour as the boundary - /// approaching and ignore the rest, rather than requesting a wake every + /// 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() 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 ad83fa02a..e4ba5ad6d 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -20,8 +20,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. From aba4b732a47d991291842bd2a8e68a973583ab35 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Sun, 16 Aug 2026 10:15:41 -0400 Subject: [PATCH 4/6] fix(host): put renewal on the core protocol and finish the grace-window correction TrUAPIHostCoreProtocol is the seam apps hold and mock, and the four methods landed on the concrete class only, so `let core: TrUAPIHostCoreProtocol` following the README does not compile. That is the same gap this branch already fixed one layer down, reintroduced one layer up. Only TrUAPIHostCore conforms, here or in the app, so widening the protocol breaks nothing. The grace-window commit corrected three places and left six saying the opposite, including the renewal module doc twenty lines above the constant contradicting it. Both host READMEs, both wrapper docs, and next_tick_delay's rationale now agree that an ended period's allowances stay active until cleanup rather than dying at the boundary. The 172800 figure was quoted in four places and read by nothing, which is the criticism the same commit levelled at the comment it replaced. A live test now asserts the window still covers at least one period, so shrinking it fails here instead of silently invalidating the scheduling guidance. Also states the active-session precondition on the Swift doc, which both READMEs and the Kotlin already carried. --- android/truapi-host/README.md | 2 +- .../kotlin/io/parity/truapi/TrUAPIHost.kt | 3 ++- ios/truapi-host/README.md | 2 +- .../Sources/TrUAPIHost/TrUAPIHost.swift | 11 +++++++- .../tests/live_people_chain.rs | 25 +++++++++++++++++++ .../runtime/statement_allowance/renewal.rs | 12 ++++++--- 6 files changed, 47 insertions(+), 8 deletions(-) diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index d9ca6222a..b564908d5 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -76,7 +76,7 @@ Both return a `Boolean` granted flag; the host renders the typed request in its ## Statement-store allowance renewal -Statement-store allowances are granted per period and lapse when the period rolls over, so a host has to re-register the accounts it wants to keep writing. The core owns the ledger and the registration; the app owns only the schedule. +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: 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 d075d6eac..a0a1c743d 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 @@ -630,7 +630,8 @@ class TrUAPIHostCore private constructor( /** * The in-process loop's own cadence, capped at an hour. Allowances only - * lapse at a period boundary, so a host scheduling one wake-up per period + * 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. */ diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index d7be22b4c..59986ccaa 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -98,7 +98,7 @@ Both return a `Bool` granted flag; the host renders the typed request in its own ## Statement-store allowance renewal -Statement-store allowances are granted per period and lapse when the period rolls over, so a host has to re-register the accounts it wants to keep writing. The runtime owns the ledger and the registration; the app owns only the schedule. +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: diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 25fa1dae5..4fab5e538 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,10 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { /// 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:)`` @@ -773,7 +781,8 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { } /// The in-process loop's own cadence, capped at an hour. Allowances only - /// lapse at a period boundary, so a host scheduling one wake-up per period + /// 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 { 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/runtime/statement_allowance/renewal.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs index e4ba5ad6d..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`. @@ -182,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; From 99cb33867de5347815ecfc239b2ac23b457b9eed Mon Sep 17 00:00:00 2001 From: tarikgul Date: Sun, 16 Aug 2026 16:29:29 -0400 Subject: [PATCH 5/6] fix(server): normalize a renewal target's product identifier `resolve_target` derives `//allowance//statement-store//{product_id}` from the string as given, while a product connection derives its account from the normalized form. A display-cased or padded identifier therefore renewed an account no product uses, and the product's real allowance lapsed at the next boundary with nothing reporting a failure. The conversion now normalizes, and rejects an identifier that is not a product id at all rather than deriving from nonsense. Documents the active-session requirement on `renew_statement_allowances` and in both scheduling sections. This is the scheduled case rather than an edge case: an OS-woken cold start has no session, and the pass fails with the bare reason `Disconnected`, which reads as a renewal failure rather than "not ready". The in-process loop needs no such care, since a tick with no session is skipped and retried. Says plainly that the surface has no reader and no untrack, so the pruning behaviour the READMEs describe is something a host can observe only as an absence from a report. Re-tracking is idempotent, so the safe habit is re-tracking the full set after an identity change. --- android/truapi-host/README.md | 4 +- ios/truapi-host/README.md | 4 +- .../Sources/TrUAPIHost/truapi_server.swift | 36 +++++++++++- rust/crates/truapi-server/src/native.rs | 58 ++++++++++++++++++- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index b564908d5..39d23e8b1 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -89,9 +89,9 @@ core.trackStatementRenewalTargets( ) ``` -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. +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: +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() diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 59986ccaa..8067bb51e 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -109,9 +109,9 @@ try runtime.trackStatementRenewalTargets([ ]) ``` -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. +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: +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() diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 74f6f0c9a..527635474 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -3335,6 +3335,13 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { * 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 @@ -3517,6 +3524,13 @@ open func openProductExecution(callbacks: HostCallbacks, chatCallbacks: NativeCh * 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) { @@ -4564,6 +4578,14 @@ enum NativeRenewalTargetError: Swift.Error, Equatable, Hashable, Foundation.Loca * 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. */ @@ -4604,7 +4626,10 @@ public struct FfiConverterTypeNativeRenewalTargetError: FfiConverterRustBuffer { case 1: return .InvalidAccountId( actual: try FfiConverterUInt64.read(from: &buf) ) - case 2: return .Rejected( + case 2: return .InvalidProductId( + productId: try FfiConverterString.read(from: &buf) + ) + case 3: return .Rejected( reason: try FfiConverterString.read(from: &buf) ) @@ -4624,8 +4649,13 @@ public struct FfiConverterTypeNativeRenewalTargetError: FfiConverterRustBuffer { FfiConverterUInt64.write(actual, into: &buf) - case let .Rejected(reason): + 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) } @@ -6187,7 +6217,7 @@ 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() != 29034) { + 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) { diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 27ed1537b..62db03116 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -645,6 +645,12 @@ pub enum NativeRenewalTargetError { /// 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 { @@ -659,7 +665,14 @@ impl TryFrom for crate::runtime::StatementRenewalT fn try_from(target: NativeStatementRenewalTarget) -> Result { Ok(match target { NativeStatementRenewalTarget::ProductStatementAllowance { product_id } => { - Self::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 } => { @@ -731,6 +744,13 @@ impl NativeTrUApiHostRuntime { /// 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 { @@ -1771,6 +1791,42 @@ mod tests { } } + /// 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] From 9151fd0a2ba1025e7174edfea24b8e07464b1723 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Sun, 16 Aug 2026 17:30:50 -0400 Subject: [PATCH 6/6] docs(server): document the renewal calls where Android actually reads them The session precondition was added to NativeTrUApiHostRuntime, and the review asked for it because it flows into both bindings. It did not: NativeTrUApiCore's copies said only `See [NativeTrUApiHostRuntime::...]`, which renders in Swift and Kotlin as a literal Rust path to a type the Kotlin shell does not expose, and NativeTrUApiCore is Android's only route. The generated Kotlin carried zero mentions of the precondition. All four delegating methods now carry the substance rather than a pointer, so the precondition, the append-only ledger, the tolerance of the in-process loop, and the grace window reach both bindings. The precondition appears six times in the generated Swift and Kotlin now, against zero in Kotlin before. --- .../Sources/TrUAPIHost/truapi_server.swift | 80 ++++++++++++++++--- rust/crates/truapi-server/src/native.rs | 36 ++++++++- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 527635474..a48d3721f 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -2792,7 +2792,10 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { func disconnect() /** - * See [`NativeTrUApiHostRuntime::next_statement_renewal_delay`]. + * 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 @@ -2838,7 +2841,18 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { func permissionAuthorizationStatus(request: PermissionAuthorizationRequest) throws -> PermissionAuthorizationStatus /** - * See [`NativeTrUApiHostRuntime::renew_statement_allowances`]. + * 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 @@ -2870,7 +2884,11 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { func setPermissionAuthorizationStatus(request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus) throws /** - * See [`NativeTrUApiHostRuntime::start_statement_allowance_renewal`]. + * 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() @@ -2886,7 +2904,17 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { func stopWsBridge() /** - * See [`NativeTrUApiHostRuntime::track_statement_renewal_targets`]. + * 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 @@ -3018,7 +3046,10 @@ open func disconnect() {try! rustCall() { } /** - * See [`NativeTrUApiHostRuntime::next_statement_renewal_delay`]. + * 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() { @@ -3115,7 +3146,18 @@ open func permissionAuthorizationStatus(request: PermissionAuthorizationRequest) } /** - * See [`NativeTrUApiHostRuntime::renew_statement_allowances`]. + * 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) { @@ -3186,7 +3228,11 @@ open func setPermissionAuthorizationStatus(request: PermissionAuthorizationReque } /** - * See [`NativeTrUApiHostRuntime::start_statement_allowance_renewal`]. + * 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 @@ -3222,7 +3268,17 @@ open func stopWsBridge() {try! rustCall() { } /** - * See [`NativeTrUApiHostRuntime::track_statement_renewal_targets`]. + * 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 @@ -6151,7 +6207,7 @@ 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() != 22349) { + 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) { @@ -6172,7 +6228,7 @@ 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() != 16355) { + 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) { @@ -6187,7 +6243,7 @@ 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() != 42790) { + 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) { @@ -6196,7 +6252,7 @@ private let initializationResult: InitializationResult = { 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() != 61871) { + 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) { diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 62db03116..41fd9865b 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -1130,7 +1130,17 @@ impl NativeTrUApiCore { self.host.activate_local_session(secret, lite_username) } - /// See [`NativeTrUApiHostRuntime::track_statement_renewal_targets`]. + /// 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, @@ -1138,19 +1148,37 @@ impl NativeTrUApiCore { self.host.track_statement_renewal_targets(targets) } - /// See [`NativeTrUApiHostRuntime::renew_statement_allowances`]. + /// 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() } - /// See [`NativeTrUApiHostRuntime::start_statement_allowance_renewal`]. + /// 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(); } - /// See [`NativeTrUApiHostRuntime::next_statement_renewal_delay`]. + /// 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() }