Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions android/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,39 @@ The core's `Permissions` platform trait has two methods, and so does the bridge:

Both return a `Boolean` granted flag; the host renders the typed request in its own prompt UI. The same typed values drive the `TrUAPIHostCore` permission admin API (`permissionAuthorizationStatus`, `setPermissionAuthorizationStatus`), which reads and updates the persisted decisions without prompting.

## Statement-store allowance renewal

Statement-store allowances are granted per period, so a host has to re-register the accounts it wants to keep writing. They are not revoked the moment the period ends: `Resources.StmtStoreGraceWindow` keeps an ended period's allowances active until cleanup catches up, 48 hours on `paseo-next-v2`. The core owns the ledger and the registration; the app owns only the schedule.

Record the accounts to keep allowed. This needs an active session, so call it after `activateLocalSession` or after pairing, not at construction:

```kotlin
core.trackStatementRenewalTargets(
listOf(
NativeStatementRenewalTarget.WalletSso,
NativeStatementRenewalTarget.Account(deviceStatementKey, "device"),
),
)
```

The ledger persists across launches, and it is append-only: there is no untrack, and an entry is dropped only when the identity that promised it changes. `WalletSso` and `ProductStatementAllowance` are derivation recipes and survive that; `Account` carries a fixed account id and does not, so re-track raw accounts whenever the active identity changes. A pruned target is absent from the report rather than reported as failed. There is no reader and no untrack on this surface: a host cannot list what is tracked, cannot remove a wrong entry, and cannot detect a pruned one except by noticing it missing from a report. Re-tracking is idempotent, so the safe habit is to re-track the full set after every identity change rather than trying to reason about what survived.

Then run a pass from a `WorkManager` worker. It submits extrinsics and blocks until they are included, so keep it off the main thread. It needs an active session too, which is the whole difficulty here: a worker on a cold start has none until you restore one, and the pass then fails with the bare reason `Disconnected`. Restore the session first, and read that reason as "not ready" rather than as a renewal failure. `startStatementAllowanceRenewal()` does not need this care, since its loop skips a tick with no session and retries.

```kotlin
val report = core.renewStatementAllowances()
report.outcomes.forEach { Log.i(TAG, "${it.label}: ${it.status}") }
if (report.slotsExhausted) {
// Every slot for this period is taken and none was replaceable.
}
```

One scheduled pass per period is enough, with room to spare: an allowance stays usable for `Resources.StmtStoreGraceWindow` past its boundary, which is 48 hours on `paseo-next-v2`, so a missed run is recoverable rather than fatal. `nextStatementRenewalDelay()` reports the in-process loop's retry cadence, capped at an hour; a worker scheduling one run per period should read a value under an hour as the boundary approaching rather than waking hourly.

`startStatementAllowanceRenewal()` runs the same pass on an in-process loop instead, for a host that stays resident. A pass has no cancellation, so several targets can outlast a constrained worker budget; targets registered before the process is killed are not lost and read back as already allocated.

An account id must be exactly 32 bytes. Anything else throws `NativeRenewalTargetException.InvalidAccountId` before any chain work happens.

## Example

> **Threading:** the Rust core invokes every `HostBridge` callback on a
Expand Down
48 changes: 48 additions & 0 deletions android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ import uniffi.truapi_server.HostNavigateRejection
import uniffi.truapi_server.HostRejection
import uniffi.truapi_server.HostStorageException
import uniffi.truapi_platform.ProductExecutionKind as UniFfiProductExecutionKind
import uniffi.truapi_server.NativeRenewalTargetException
import uniffi.truapi_server.NativeRuntimeConfigException
import uniffi.truapi_server.NativeStatementRenewalTarget
import uniffi.truapi_server.NativeTrUApiCore
import uniffi.truapi_server.StatementRenewalReport
import uniffi.truapi_server.WsBridgeEndpoint
import uniffi.truapi_server.WsBridgeStartException
import uniffi.truapi_server.NativePairingDeeplinkScheme as UniFfiNativePairingDeeplinkScheme
Expand Down Expand Up @@ -593,6 +596,51 @@ class TrUAPIHostCore private constructor(
inner.activateLocalSession(secret, liteUsername)
}

/**
* Record the accounts renewal should keep allowed on the Statement Store.
* Needs an active session, so call it after [activateLocalSession] or after
* pairing, not at construction.
*
* The ledger is append-only: there is no untrack, and a target is only
* dropped when the identity that promised it changes. Recipe-shaped targets
* survive that; a raw [NativeStatementRenewalTarget.Account] does not, so
* re-track those whenever the active identity changes.
*/
@Throws(NativeRenewalTargetException::class)
fun trackStatementRenewalTargets(targets: List<NativeStatementRenewalTarget>) {
inner.trackStatementRenewalTargets(targets)
}

/**
* Run one renewal pass now, reporting what each tracked target got.
*
* Submits extrinsics and blocks until they are included, so call it from a
* WorkManager worker rather than the main thread. There is no cancellation:
* a pass with several targets can outlast a short background budget, though
* a target registered before the process is killed is not lost and reads
* back as already allocated.
*/
@Throws(HostRejection::class)
fun renewStatementAllowances(): StatementRenewalReport = inner.renewStatementAllowances()

/**
* Start the in-process renewal loop, for a host that stays resident. A
* suspended app stops ticking, so prefer scheduling
* [renewStatementAllowances].
*/
fun startStatementAllowanceRenewal() {
inner.startStatementAllowanceRenewal()
}

/**
* The in-process loop's own cadence, capped at an hour. Allowances only
* stop being renewed at a period boundary and survive it by the chain's
* grace window, so a host scheduling one wake-up per period
* should read a value under an hour as the boundary approaching rather than
* waking hourly.
*/
fun nextStatementRenewalDelay(): java.time.Duration = inner.nextStatementRenewalDelay()

/** Read a stored permission authorization status without prompting. */
@Throws(HostRejection::class)
fun permissionAuthorizationStatus(
Expand Down
35 changes: 35 additions & 0 deletions ios/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,41 @@ The core's `Permissions` platform trait has two methods, and so does `HostCallba

Both return a `Bool` granted flag; the host renders the typed request in its own prompt UI. The same typed values drive the `TrUAPIHostCore` permission admin API (`permissionAuthorizationStatus`, `setPermissionAuthorizationStatus`), which reads and updates the persisted decisions without prompting.

## Statement-store allowance renewal

Statement-store allowances are granted per period, so a host has to re-register the accounts it wants to keep writing. They are not revoked the moment the period ends: `Resources.StmtStoreGraceWindow` keeps an ended period's allowances active until cleanup catches up, 48 hours on `paseo-next-v2`. The runtime owns the ledger and the registration; the app owns only the schedule.

Record the accounts to keep allowed. This needs an active session, so call it after `activateLocalSession` or after pairing, not at construction:

```swift
try runtime.trackStatementRenewalTargets([
.walletSso,
.account(accountId: deviceStatementKey, label: "device"),
])
```

The ledger persists across launches, and it is append-only: there is no untrack, and an entry is dropped only when the identity that promised it changes. `.walletSso` and `.productStatementAllowance` are derivation recipes, so they survive that; `.account` carries a fixed account id and does not. Re-track raw accounts whenever the active identity changes, or renewal quietly stops covering them — a pruned target is absent from the report rather than reported as failed. There is no reader and no untrack on this surface: a host cannot list what is tracked, cannot remove a wrong entry, and cannot detect a pruned one except by noticing it missing from a report. Re-tracking is idempotent, so the safe habit is to re-track the full set after every identity change rather than trying to reason about what survived.

Then run a pass from a background task, off the main thread. It needs an active session too, which is the whole difficulty here: a `BGTaskScheduler` wake on a cold start has none until you restore one, and the pass then fails with the bare reason `Disconnected`. Restore the session first, and read that reason as "not ready" rather than as a renewal failure. `startStatementAllowanceRenewal()` does not need this care, since its loop skips a tick with no session and retries.

```swift
let report = try runtime.renewStatementAllowances()
for outcome in report.outcomes {
log("\(outcome.label): \(outcome.status)")
}
if report.slotsExhausted {
// Every slot for this period is taken and none was replaceable.
}
```

One scheduled pass per period is enough, with room to spare: an allowance stays usable for `Resources.StmtStoreGraceWindow` past its boundary, which is 48 hours on `paseo-next-v2`, so a missed wake-up is recoverable rather than fatal. `nextStatementRenewalDelay()` reports the in-process loop's retry cadence, capped at an hour; a `BGTaskScheduler` host should read a value under an hour as the boundary approaching rather than requesting a wake-up every hour for a pass that will almost always report `alreadyAllocated`.

`startStatementAllowanceRenewal()` runs the same pass on an in-process loop instead. It suits a host that stays resident; on iOS a suspended app stops ticking, so prefer `BGTaskScheduler` driving the one-shot call. A pass has no cancellation, so several targets can outlast a short background budget; targets registered before the process is killed are not lost, and read back as already allocated next time.

An account id must be exactly 32 bytes. Anything else is rejected as `NativeRenewalTargetError.InvalidAccountId` before any chain work happens.

`TrUAPIHostCore` exposes the same four calls for hosts that use it instead of `TrUAPIHostRuntime`.

## Example

> **Threading:** the Rust core invokes every `HostCallbacks` method on a
Expand Down
92 changes: 92 additions & 0 deletions ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -747,6 +751,69 @@ public final class TrUAPIHostRuntime: @unchecked Sendable {
public func notifyChainClosed(connectionId: UInt32) {
inner.notifyChainClosed(connectionId: connectionId)
}

/// Record the accounts renewal should keep allowed on the Statement Store.
///
/// Needs an active session, so call it after
/// ``activateLocalSession(secret:liteUsername:)`` or after pairing, not at
/// construction.
///
/// Recipe-shaped targets survive a change of root entropy; a raw
/// ``StatementRenewalTarget/account(accountId:label:)`` does not, and is
/// dropped by the next pass after ``activateLocalSession(secret:liteUsername:)``
/// installs a different identity. Re-track those whenever the identity changes.
public func trackStatementRenewalTargets(_ targets: [StatementRenewalTarget]) throws {
try inner.trackStatementRenewalTargets(targets: targets.map(\.native))
}

/// Run one renewal pass now, reporting what each tracked target got.
///
/// Submits extrinsics and blocks until they are included, so call it off the
/// main thread. There is no cancellation: a pass with several targets can
/// outlast a short background budget, though a target registered before the
/// process is killed is not lost and reads back as already allocated.
public func renewStatementAllowances() throws -> StatementRenewalReport {
try inner.renewStatementAllowances()
}

/// Start the in-process renewal loop, for a host that stays resident. A
/// suspended app stops ticking, so prefer scheduling
/// ``renewStatementAllowances()``.
public func startStatementAllowanceRenewal() {
inner.startStatementAllowanceRenewal()
}

/// The in-process loop's own cadence, capped at an hour. Allowances only
/// stop being renewed at a period boundary and survive it by the chain's
/// grace window, so a host scheduling one wake-up per period
/// should read a value under an hour as the boundary approaching rather
/// than waking hourly.
public func nextStatementRenewalDelay() -> TimeInterval {
inner.nextStatementRenewalDelay()
}
}

/// An account renewal should keep allowed on the Statement Store.
public enum StatementRenewalTarget: Sendable {
/// The statement-store allowance account derived for one product. Resolves
/// under whatever root entropy is active, so it survives a rotation.
case productStatementAllowance(productId: String)
/// The wallet's own SSO account. Also a derivation, so it survives a rotation.
case walletSso
/// A fixed account, such as a pairing peer's device statement key. Must be
/// exactly 32 bytes, and is dropped when the promising identity changes.
case account(accountId: Data, label: String)

var native: NativeStatementRenewalTarget {
switch self {
case let .productStatementAllowance(productId):
.productStatementAllowance(productId: productId)
case .walletSso:
.walletSso
case let .account(accountId, label):
.account(accountId: accountId, label: label)
}
}
}

/// Testable surface for one connection-scoped product execution.
Expand Down Expand Up @@ -934,6 +1001,31 @@ public final class TrUAPIHostCore: TrUAPIHostCoreProtocol {
try inner.activateLocalSession(secret: secret, liteUsername: liteUsername)
}

/// Record the accounts renewal should keep allowed on the Statement Store.
/// See ``TrUAPIHostRuntime/trackStatementRenewalTargets(_:)``.
public func trackStatementRenewalTargets(_ targets: [StatementRenewalTarget]) throws {
try inner.trackStatementRenewalTargets(targets: targets.map(\.native))
}

/// Run one renewal pass now. Blocks until the extrinsics are included, so
/// call it off the main thread. See
/// ``TrUAPIHostRuntime/renewStatementAllowances()``.
public func renewStatementAllowances() throws -> StatementRenewalReport {
try inner.renewStatementAllowances()
}

/// Start the in-process renewal loop. See
/// ``TrUAPIHostRuntime/startStatementAllowanceRenewal()``.
public func startStatementAllowanceRenewal() {
inner.startStatementAllowanceRenewal()
}

/// The in-process loop's cadence, capped at an hour. See
/// ``TrUAPIHostRuntime/nextStatementRenewalDelay()``.
public func nextStatementRenewalDelay() -> TimeInterval {
inner.nextStatementRenewalDelay()
}

/// Read a stored permission authorization status without prompting.
public func permissionAuthorizationStatus(
request: PermissionAuthorizationRequest
Expand Down
Loading