Skip to content

Pro redemption reflow (iOS): implicit redemption, libsession renewal/rotation - #723

Open
jagerman wants to merge 6 commits into
session-foundation:devfrom
jagerman:pro-redemption-reflow
Open

Pro redemption reflow (iOS): implicit redemption, libsession renewal/rotation#723
jagerman wants to merge 6 commits into
session-foundation:devfrom
jagerman:pro-redemption-reflow

Conversation

@jagerman

Copy link
Copy Markdown
Member

See PRs session-foundation/session-pro-backend#4 and session-foundation/libsession-util#104

Brings the iOS Pro client onto the redemption-reflow model, matching the other platforms.

Payment flow. Redemption is implicit via generate_pro_proof — the old add-payment call
and its response type are replaced by GenerateProProofResponse — and set_refund_requested
is gone, with the refund-pending flag now living in user config.

Renewal. SessionProManager gains the same reconcile behaviour as the other clients:
libsession owns renewal timing and the deterministic rotating key, the stored proof is
replaced only when coverage extends, the cached account expiry is refreshed from the proof
response, a lapse clears the proof only when no valid one is held, and revocation is
terminal. Self-revocation clears our own credential, and a proof is attached to outgoing
messages only when it is both unexpired and unrevoked.

Version: bumped to 2.16.0 (MARKETING_VERSION 2.15.4 → 2.16.0).

Note: needs libsession-util-spm re-published at the merged dev tip and the iOS pin
bumped before it will build in CI.

jagerman and others added 6 commits July 29, 2026 20:24
…l/rotation, config-synced refund/prepaid

Aligns iOS with the 5-repo Session Pro "redemption reflow" (against libsession 58bafc4a).

1. /add_pro_payment is gone — redemption is implicit. Deleted addProPayment (and the
   purchase retry loop + the Transaction.updates add-payment call); after a purchase the
   client just requests a proof via generate_pro_proof and the backend binds the account's
   unbound payments on any master-signed request. Removed the setPaymentRefundRequested and
   add_pro_payment request builders and the now-dead SetPaymentRefundRequestedResponse; the
   AddProPaymentOrGenerateProProofResponse type collapses to GenerateProProofResponse.

2. Rotating seed + renewal owned by libsession. Rotating key is now the deterministic weekly
   seed via session_protocol_pro_rotating_seed (new sessionProRotatingKeyPair generator),
   replacing the ad-hoc self.rotatingKeyPair ?? ed25519KeyPair(). Renewal timing polls
   user_profile_get_pro_renewal_target(now) (<= now ⇒ renew) instead of the buggy
   autoRenewing-gated needsNewProof logic.

3. Refund state is config-synced. requestRefund records it via user_profile_set_refund_requested
   after Apple's beginRefundRequest (no backend call); the pending flag reads from
   user_profile_get_refund_requested. Dropped refund_requested_ts from PaymentItem /
   GetProStatusResponse and carry it on SessionPro.State (drives refundingStatus).

4. pro_prepaid "purchase in flight" marker set in purchasePro's .success branch
   (user_profile_set_pro_prepaid) so other devices poll the entitlement through.

5. Client-side prepaid poll (startPrepaidPoll): while pro_prepaid is set and not-yet-Pro,
   poll with a capped exponential backoff; stops when the marker clears (entitlement landed,
   already Pro, or libsession's one-week staleness gate).

6. §4 C-API: dropped the removed sent_timestamp_ms arg from
   session_protocol_encode_for_community_inbox. (iOS never calls session_encrypt_for_group /
   session_decrypt_group_message, and encode_for_group is unchanged, so nothing else to do.)

Adds the refund/prepaid/renewal accessors to LibSessionCacheType (+ no-op defaults + mock).
Built green (app + tests) via Session_CompileLibSession.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
libsession's session_protocol_pro_features_for_utf8/_utf16 are gone (as of a4809f7c),
replaced by session_protocol_pro_features_for_message(codepoint_count) — it no longer
inspects the text; the caller passes the codepoint count.

- messageFeatures(for:): pass message.unicodeScalars.count (the Unicode codepoint count
  for an always-valid Swift String; surrogate pair = 1, matching libsession's simdutf
  path) instead of round-tripping the UTF-8 bytes through libsession to count.
- numberOfCharactersLeft(for:): collapse to characterLimit - content.unicodeScalars.count
  (no messageFeatures round-trip).
- FeatureStatus: drop .utfDecodingError (and its UTF_DECODING_ERROR mappings) — there's no
  text for libsession to fail decoding on; only .success / .exceedsCharacterLimit remain
  (unknown defaults to .success).
- FeaturesForMessage: drop codePointCount (output field removed from the C struct — it only
  echoed the input) and the now-unreachable .invalidString.
- ConversationViewModel: drop the .utfDecodingError case and its .utf16.count fallback.

NOTE: iOS verification uses Session_CompileLibSession (source at a4809f7c), so this is NOT
gated on the SPM re-publish (task session-foundation#24) — that only affects the default scheme / CI. Built
green (app + tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session/Conversations/Input View/InputView.swift was tracked in git but had no fileRef in
Session.xcodeproj, so it was never compiled — a leftover from promoting the input view into
SessionUIKit as a public class. It also referenced LibSession.numberOfCharactersLeft(for:isSessionPro:),
which exists nowhere in the tree (only invisible because the file never compiled). The live
input view is SessionUIKit/Components/Input View/InputView.swift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n window

The client passes raw `now` into session_protocol_pro_rotating_seed and does no window/period
arithmetic — but two of my comments characterized the schedule as 'weekly' / 'rotation period'.
The rotation schedule is libsession-owned and may change; reword to 'the seed for now, libsession
owns the schedule'. Comment-only; no code change. (Mirrors betwixt's android/desktop wording pass.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the single-idle-device renewal gap (valid subscription, proof near/at expiry, nothing
was triggering a renewal) using the settled cross-client reconcile contract.

Renewal (scheduleNextProofRenewal): deterministic reconcile driven entirely by libsession's
pro_renewal_target(now) — no client-side window or jitter. Due (<= now) → refreshProState
(re-confirms the backend subscription status, since a lapsed local proof flips inferred status
to .expired while the subscription is still active — then regenerates the proof); future →
Task.sleep to the target; retry-on-failure keyed on proof validity (dark 15s / covered 60s, no
backoff). Loop state is ephemeral (re-derived from config each pass), so suspension / process
death recover on the next reconcile; refreshProState's single-flight guard prevents two
generate_pro_proof in flight.

Triggers: willEnterForeground (the robust one — iOS suspends the process so a Task.sleep across
a background period is untrustworthy; the foreground re-run catches anything that lapsed while
suspended, before the user can send), plus init and updateWithLatestFromUserConfig (cross-device
proof updates). The in-foreground Task.sleep is the secondary path for the app-stays-open case.

Send rule 1: attachProInfoIfNeeded now attaches a currently-valid proof or NONE (proProofIsActive
guard) — never a known-expired one. Sending is never blocked by a lapsed proof; the message just
goes out without Pro metadata.

startPrepaidPoll still owns the purchase-in-flight (dark/no-proof) case; it folds into this loop
once libsession's renewal_target entitlement/prepaid gate lands (deferred per betwixt). Built
green (app + tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…epaid folded, §6 revocation)

Folds the iOS renewal loop into the agreed cross-client design-of-record (Rev 2), against
libsession 078c611b (gated pro_renewal_target + account_expiry_ts on the proof response).

Reconcile loop (§2): reconcileProofRenewal is now the event-driven RECONCILE — reads
pro_renewal_target(now) (NONE→dormant / future→one wake / <=now→due), with ephemeral
lastRequestAt + darkAttempt spacing. Covered spaces flat 60s; dark uses the linear backoff
min(15s×darkAttempt, 900s) (covered resets darkAttempt). Single-flight is best-effort via the
spacing check — overlap accepted (§1.9; the §4 monotonic merge no-ops a byte-identical late
duplicate). The advisory in-foreground wake re-runs reconcile; the guarantee is the trigger set
(willEnterForeground / config-sync / init / purchase / Transaction.updates). No unarmed-exit
(the gate returns NONE for non-entitled), no durable state.

Renewal action (§1.3): pure generate_pro_proof — refreshProState/get_pro_status is display-only
now (auto-renew/grace/refund fields) and no longer mints or clears the proof. Preserves the
ensureNetworkConnection wait so a connectivity outage recovers promptly rather than backoff-lagged.

ON_COMPLETE (§4), reads live config inside one atomic mutation: success → replace proof iff
new.expiry > current.expiry + write E←account_expiry (ungated, H4); subscription_expired /
not_subscribed → clear iff no unexpired proof (leaves pro_prepaid, §7.3); revoked → terminal
clear, no E; transient / unrecognized → nothing (opaque-value fail-closed).

Prepaid poll folded in: removed startPrepaidPoll — a pending purchase surfaces as
renewal_target<=now (dark path) via the gate. purchasePro / Transaction.updates now
markPurchaseInFlight + reconcile.

Send (§6): attach requires unexpired AND unrevoked (currentUserProofIsValid); the revocation-list
job clears our own credential when our own proof is revoked (clearOwnCredentialIfRevoked).

GenerateProProofResponse gains accountExpiryTimestampSeconds + the typed Outcome
(success/subscriptionExpired/notSubscribed/revoked/transient) from header.status + error_code slug.
Built green (app + tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@mpretty-cyro mpretty-cyro 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.

A couple of really minor (non-functional) comments, I'll push up a tweak for them

Opened the code in Xcode and can see that the minor comments are on things that are actually needed so no changes needed

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.

2 participants