feat(host): drive statement-store allowance renewal from native hosts - #417
Merged
Conversation
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.
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.
Collaborator
|
One question rather than a request: |
Imod7
approved these changes
Aug 16, 2026
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
NativeTrUApiHostRuntimeandNativeTrUApiCore, forwarded by both host shells:trackStatementRenewalTargetsrenewStatementAllowancesstartStatementAllowanceRenewalnextStatementRenewalDelayTracking 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.outcomeswas aVec<(String, TargetRenewalStatus)>and UniFFI has no tuple type, so the pair is now a namedStatementRenewalOutcome, which also names the label at each use site instead of leaving it positional.StatementRenewalTargetcarries a[u8; 32]account id where UniFFI carries bytes asVec<u8>, soNativeStatementRenewalTargetmirrors 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
StatementRenewalTargetenum so the public API does not carry the generatedNativeprefix. Kotlin bindings are generated on demand and need no update; the committed Swift bindings are regenerated here, sosync-bindings.sh --checkstays 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.
nextStatementRenewalDelayis the loop's hourly retry rhythm, not a once-per-period schedule.Verification
Live through the CLI:
/renewagainst the People chain registered a target for period 20680 and included it at0x4968979e, 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_INTERVALdescribed its one-hour cap as "mirroring the on-chain graceperiod after a period boundary". The runtime declares that period as
Resources.StmtStoreGraceWindow, which reads 172800 onpaseo-next-v2, so thecomment 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:
LiteStmtStoreSlotsPerPeriod10,StmtStoreSlotsPerPeriod20,StmtStoreReplacementCooldown60,LongTermStoragePeriodDuration1209600.