Skip to content

feat(host): drive statement-store allowance renewal from native hosts - #417

Merged
TarikGul merged 8 commits into
mainfrom
tg/renewal-ffi-export
Aug 16, 2026
Merged

feat(host): drive statement-store allowance renewal from native hosts#417
TarikGul merged 8 commits into
mainfrom
tg/renewal-ffi-export

Conversation

@TarikGul

@TarikGul TarikGul commented Aug 15, 2026

Copy link
Copy Markdown
Member

Statement-store allowances are granted per period and lapse at the boundary, so a host has to re-register the accounts it wants to keep writing. The renewal engine has been in the core since #308; this makes it reachable from iOS and Android.

Four calls on NativeTrUApiHostRuntime and NativeTrUApiCore, forwarded by both host shells:

Call For
trackStatementRenewalTargets record what to keep allowed; the ledger persists
renewStatementAllowances one pass, driven by BGTaskScheduler or WorkManager
startStatementAllowanceRenewal in-process loop, for a host that stays resident
nextStatementRenewalDelay the loop's cadence, for scheduling

Tracking is part of the surface because targets are registered explicitly by the host. Without it a native host would renew an empty ledger forever.

Shape

Two types could not cross UniFFI as they were. StatementRenewalReport.outcomes was a Vec<(String, TargetRenewalStatus)> and UniFFI has no tuple type, so the pair is now a named StatementRenewalOutcome, which also names the label at each use site instead of leaving it positional. StatementRenewalTarget carries a
[u8; 32] account id where UniFFI carries bytes as Vec<u8>, so 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.

Swift gets its own StatementRenewalTarget enum so the public API does not carry the generated Native prefix. Kotlin bindings are generated on demand and need no update; the committed Swift bindings are regenerated here, so sync-bindings.sh --check stays green.

Behaviour worth knowing, documented in both host READMEs

The ledger is append-only. There is no untrack, and an entry leaves only when the identity that promised it changes, which drops raw account targets while derivation recipes survive. A dropped target is absent from the report rather
than reported as failed, so a host cannot detect it. Tracking needs an active session. A pass has no cancellation and can outlast a short background budget. nextStatementRenewalDelay is the loop's hourly retry rhythm, not a once-per-period schedule.

Verification

Live through the CLI: /renew against the People chain registered a target for period 20680 and included it at 0x4968979e, then reported it as already allocated on a second pass.

iOS: the package builds and its test passes on an iPhone 17 Pro simulator, a real WS-bridge round trip against a locally built xcframework. The full polkadot-app also builds and runs against this branch on the simulator.

Offline: fmt, clippy with --all-targets --all-features -D warnings, 782 tests, wasm32 under -D warnings, and the committed iOS bindings check.

Scope

Closes the FFI half of #334's renewal item. That box also names Coinage (#344), which is separate. Neither app calls renewal yet; iOS already has the scheduling pattern in CoinageRecyclingTaskRegistrator.

A constant that documented itself wrongly

MAX_TICK_INTERVAL described its one-hour cap as "mirroring 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 the code never reads and was out by 48x.

The cap itself is fine as a retry rhythm for the in-process loop. What changes is
the guidance built on it: an allowance stays usable for two days past its
boundary, so a host scheduling one pass per period has real slack and a missed
wake-up is recoverable rather than fatal. That is now stated on the constant, on
the exported delay accessor, and in the scheduling section of both host READMEs.

Confirmed against live metadata alongside the values the core already reads:
LiteStmtStoreSlotsPerPeriod 10, StmtStoreSlotsPerPeriod 20,
StmtStoreReplacementCooldown 60, LongTermStoragePeriodDuration 1209600.

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<u8>. 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.
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.
@TarikGul
TarikGul requested a review from a team August 15, 2026 19:51
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.
…ow 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.
@filvecchiato

Copy link
Copy Markdown
Collaborator

One question rather than a request: live_grace_window_still_leaves_a_full_period_of_slack is the only mechanized check on the 172800s figure that the scheduling guidance rests on, and it is #[ignore]d — nothing in CI, scripts/, or the Makefile passes --ignored. How is it meant to be triggered? If the answer is "by hand when the runtime changes", worth saying so on the test, otherwise the guard reads as automated when it is not.

@filvecchiato filvecchiato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Comment thread rust/crates/truapi-server/src/native.rs
Comment thread rust/crates/truapi-server/src/native.rs
Comment thread android/truapi-host/README.md Outdated
`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.
… 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.
@TarikGul
TarikGul added this pull request to the merge queue Aug 16, 2026
Merged via the queue into main with commit b4292f6 Aug 16, 2026
16 checks passed
@TarikGul
TarikGul deleted the tg/renewal-ffi-export branch August 16, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants