From e9f859759d4fb2be675bb1b4da1f905eb1150597 Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:19:05 +0000 Subject: [PATCH] fixup! docs: describe badge tier changes and restore as the service handles them --- apps/ios/product/gaps.md | 2 +- apps/multiplatform/product/gaps.md | 7 ++++--- docs/protocol/badges-overview.md | 10 +++++----- docs/protocol/badges-rpc.md | 6 +++--- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md index 8968103b1e..26319deb72 100644 --- a/apps/ios/product/gaps.md +++ b/apps/ios/product/gaps.md @@ -66,7 +66,7 @@ The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages ### GAP: A badge moves to another device or profile only once in a while, and is bought again only to continue the last purchase **Source:** [Shared/Views/Badges/BadgeStore.swift](../Shared/Views/Badges/BadgeStore.swift) -`restorePurchases()` hands over `Transaction.currentEntitlements` and `Transaction.unfinished` as restored, and the service moves the subscription to the restoring profile's purchase key. A payment moves again only once the credential issued to the key it last moved from has expired; until then Restore is answered `transfer_limited`, and the first Restore of a limited payment adds nothing and shows nothing, so only the next Restore says why. A badge bought with Stripe or crypto cannot be moved at all. On the same install a lapsed subscriber can subscribe again only to the badge the active profile's newest purchase retired: core resumes that purchase on the new charge, as Apple keeps the original transaction id. The app's gate (`forSale`, comparing the original ids in `Transaction.all`, read once a run, with the purchase's `transactionRef`) shuts subscriptions for any other profile, device or badge, and for the rest of the run after a purchase that awaits approval, which StoreKit does not list. +`restorePurchases()` hands over `Transaction.currentEntitlements` and `Transaction.unfinished` as restored, and the service moves the subscription to the restoring profile's purchase key. Restore is refused while the restoring profile shows a badge. A payment moves again only once the credential issued to the key it last moved from has expired; until then the move is refused `transfer_limited`. The first Restore's own answer doesn't say so: the app hears of the refusal only from core's purchase-refused event, which core sends only for a record holding an invoice id, and the next Restore is answered `transfer_limited` itself. A badge bought with Stripe or crypto cannot be moved at all. On the same install a lapsed subscriber can subscribe again only to the badge the active profile's newest purchase retired: core resumes that purchase on the new charge, as Apple keeps the original transaction id. The app's gate (`forSale`, comparing the original ids in `Transaction.all`, read once a run, with the purchase's `transactionRef`) shuts subscriptions for any other profile, device or badge, and for the rest of the run after a purchase that awaits approval, which StoreKit does not list. **REC:** Restore by a receipt code for Stripe and crypto purchases, and announce a limited move on the first Restore. diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index 9d82529921..41a4190e4a 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -355,13 +355,13 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag ## GAP-10: Badges: Limits on Moving a Badge to a New Device or Profile -**Severity:** Low +**Severity:** Medium **Category:** Feature / Payments **Platforms:** Android ### Description -Play gives a resubscription a new purchase token, so subscribing again on the same install is credited as a new purchase. On a new device or under another profile, Restore hands over the subscriptions in Play's list as restored, and the service moves the subscription to the restoring profile's purchase key. A payment moves again only once the credential issued to the key it last moved from has expired; until then Restore is answered `transfer_limited`, and the first Restore of a limited payment adds nothing and shows nothing, so only the next Restore says why. A badge bought with Stripe or crypto cannot be moved at all. +Play gives a resubscription a new purchase token, so subscribing again on the same install is credited as a new purchase. On a new device or under another profile, Restore hands over the subscriptions in Play's list as restored, and the service moves the subscription to the restoring profile's purchase key. Restore is refused while the restoring profile shows a badge. A payment moves again only once the credential issued to the key it last moved from has expired; until then the move is refused `transfer_limited`. The first Restore's own answer doesn't say so: the app hears of the refusal only from core's purchase-refused event, which core sends only for a record holding an invoice id, and the next Restore is answered `transfer_limited` itself. A badge bought with Stripe or crypto cannot be moved at all. A plan change made in Play's own pages gets a new token, which the app's sweep finds but core does not record, so only Restore claims it, after the old badge has ended; until then it stays unacknowledged, which Play refunds after three days. ### Affected Locations @@ -370,9 +370,10 @@ Play gives a resubscription a new purchase token, so subscribing again on the sa ### Impact -A subscriber who moves the badge again before the previous key's credential expires waits until it does, and the first such Restore gives no reason. A badge bought with Stripe or crypto stays with the profile it was credited to. +A subscriber who moves the badge again before the previous key's credential expires waits until it does, and the first such Restore may give no reason. A badge bought with Stripe or crypto stays with the profile it was credited to. A subscriber who changes plan in Play keeps the old badge until its paid months end, and can lose the new plan to Play's refund before it is credited. ### Recommendation 1. Restore by a receipt code for Stripe and crypto purchases, shared with iOS. 2. Announce a limited move on the first Restore. +3. Credit a Play plan change's new token without waiting for Restore. diff --git a/docs/protocol/badges-overview.md b/docs/protocol/badges-overview.md index 12557507e3..856535162d 100644 --- a/docs/protocol/badges-overview.md +++ b/docs/protocol/badges-overview.md @@ -53,7 +53,7 @@ Two other uses are planned and described under [Future work](#future-work): The badge service holds the purchase key, the number of months bought, and the payment record of the purchase. A card payment is linked to the purchase, as in any other service; a user who wants the payment itself to be private may pay with cryptocurrency. In either case the record is held by the service alone, and the service does not learn where the badge is later shown or used, because a zero-knowledge proof contains nothing that refers back to the credential or the purchase. -An app store subscription's record is its store transaction, which renewals keep, and the charges the store reports for it. When Restore moves a subscription to another device or profile, the new purchase key presents the same store transaction as the old one, so the service links the two purchase keys, though not the profiles that hold them. +An app store subscription's record is its store transaction, which renewals keep, and the charges the store reports for it. When Restore purchases, the app's command that hands the store's current subscriptions to the active profile, moves a subscription to another device or profile, the new purchase key presents the same store transaction as the old one, so the service links the two purchase keys, though not the profiles that hold them. A party that verifies a proof learns the badge type and the expiry date. Since all credentials expiring in the same week have the same expiry date, these two values place the holder among the supporters whose badges expire that week. @@ -87,7 +87,7 @@ In SimpleX: | Presentations per credential | One | Unlimited | | Proof bound to the context of presentation | No | Yes | | Expiry rounded up to | The next day | The end of the following Monday | -| Issuance and presentation on different days | No | Yes | +| Renewal and presentation on different days | No | Yes | | Usable on independently operated servers | No | Yes | ### Non-goals @@ -149,7 +149,7 @@ In both cases the app generates the master key and the purchase key pair before ### Monthly issuance -A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. The months of an app store subscription are added as the store charges for them: the service asks the store whether it charged again once the paid months have run out, or at once when the app reports a higher tier. Unused months, e.g. if the app was offline, lapse at the next badge issuance. +A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. The months of an app store subscription are added as the store charges for them: the service asks the store whether it charged again once the paid months have run out, or at once when the app reports a higher tier, which it does for an App Store raise the device sees within a day of its charge. Any other change of tier, a raise seen later included, is granted at the first check after the old plan's paid months run out, though Apple bills a raise at once. Unused months, e.g. if the app was offline, lapse at the next badge issuance. Credentials are issued monthly to limit what the expiry date discloses. If credentials were issued for the whole term, a user who bought a year would hold a credential expiring on a day a year ahead, when few other credentials expire, and this date would be disclosed in every proof for a year. Instead, every credential is issued for one month, and its expiry is rounded to the end of the Monday following the end of the paid month (UTC). For example, if the paid month ends on Wednesday 14 October 2026, the credential expires at the end of Monday 19 October, and so does every credential whose month ends between Monday 12 and Sunday 18 October. All credentials whose paid month ends in the same week thus expire at the same instant. @@ -204,7 +204,7 @@ Service requests are protected by the double ratchet of the [SimpleX agent](http 1. A proof discloses no value that links it to another proof or to the purchase. 2. A proof used in one context, whether a session, a conversation or a file, cannot be used in another context. -3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. A new badge or tier — after a purchase, a redemption, a Restore, a store subscription resumed after it lapsed, or a tier raised in the store — is shown as soon as its credential is issued, so for these the request and the profile update are made together. +3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. A new badge or tier (after a purchase, a redemption, a Restore, a store subscription resumed after it lapsed, or a higher App Store tier the app reports within a day of its charge) is shown as soon as its credential is issued, so for these the request and the profile update are made together; a tier changed at a renewal check is shown when the current credential expires, as a renewal is. 4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, other than the redemptions of one multi-use code, the codes issued in the service's managed group or redeemed by its members, and a store subscription moved to a new key by Restore, which the service links to the key it moved from as the same store transaction funds both, and a request about a purchase can be made only by the holder of the purchase key. 5. A credential cannot be forged: the issuer keys are fixed in apps and servers, and the app verifies a credential before storing it. 6. A missing or failed proof leaves the default limit in place, and a server cannot be configured to grant a badge type less than the default. @@ -294,7 +294,7 @@ A code is a bearer secret until its last use is taken; after that, it is refused **Interception of a store receipt** -Restore moves a store subscription to any new purchase key that presents its store receipt, within the limit on moves, so whoever obtains the receipt can take the badge. The credential already issued on the device it left stays valid until it expires, and then Restore there can move it back. An App Store receipt stops moving the subscription once the store charges again, as only the subscription's latest transaction moves it. +Restore moves a store subscription to any new purchase key that presents its store receipt, within the limit on moves, so whoever obtains the receipt can take the badge. The credential already issued on the device it left stays valid until it expires, and then Restore there can move it back. An App Store receipt stops moving the subscription once the store charges again, as only the subscription's latest transaction moves it. A Play purchase token stays the same across renewals, so it moves the subscription for as long as the subscription renews. Each move waits only for the last credential of the key the subscription moved from, so the receipt's holder and the user can take the badge from each other in turn. **A passive network observer** diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 0d4db36119..d0515e4054 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -12,7 +12,7 @@ A request is an envelope: `version` — the client's protocol version; `purchase Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below and a store subscription moved by `transferBadge`, whose old purchase's ledger names the new key. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`. -A purchase record is created by `redeemBadgeCode`, by `getBadgeInvoice`, by `purchaseBadge` funded with `apple`, `google`, or `receipt`, or by `transferBadge`. Those commands accept a key the service holds no record of — on a first purchase it always will, and `transferBadge` requires one. Every other command answers `unknown_purchase_key` for such a key. +A purchase record is created by `redeemBadgeCode`, by `getBadgeInvoice` (not built), by `purchaseBadge` funded with `apple` or `google` (or `receipt`, post-MVP), or by `transferBadge`. Those commands accept a key the service holds no record of — on a first purchase it always will, and `transferBadge` requires one. Every other command answers `unknown_purchase_key` for such a key. ## Idempotency @@ -72,7 +72,7 @@ Always a new purchase under a new key, except App Store subscriptions, where the - Non-store (not built): `getBadgeInvoice.upgrade` — `fromPurchaseKey`, the old purchase's `receipt`, `receiptSignature` binding the old key to the new, and the asserted old `balance`. The invoice returns the conversion `credit`; settlement records `debit(upgrade)` on the old purchase and the credit on the new. - Store one-time (not built: `purchaseBadge` with `upgrade` is answered `bad_request`): an upgrade SKU at a fixed discounted price; `purchaseBadge.upgrade` — `fromPurchaseKey`, `receipt`, `receiptSignature` — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU. - App Store subscription: the client never starts a change; the user makes it in Apple's manage-subscriptions sheet, and it keeps the original transaction id, as all badge subscriptions share one subscription group. A higher tier, which Apple charges at once with a prorated refund, is sent as `upgradeBadgeSubscription` when the device sees its transaction within a day of its purchase date, and is granted as soon as the store reports the charge. Every other change — every lower tier, which Apple charges from the next renewal, a change of plan length, and a higher tier the device sees later — the client sends nothing: the service reads the change from the store and grants the badge type the store charged at its next check after coverage ends. -- Play subscription: a plan change gets a new purchase token from Play, so it arrives as a new purchase under a fresh key, credited as any first purchase is, and the old token is granted nothing more. +- Play subscription: the client never starts a plan change. One made in Play's own pages gets a new purchase token, so it is a new purchase under a fresh key, credited as a first purchase is, and the old token is granted nothing more. No client hands that token over unprompted: the app's sweep finds it in Play's list, but core records a listed token only when it already holds a record of it, so only Restore hands it over, and Restore is refused while the profile shows a badge. Until then it stays unacknowledged, which Play refunds after three days (see Google Play above). ## Catalog @@ -84,7 +84,7 @@ The ledger is written by the service alone (model §3); the client keeps a verba `statement` — `entries`, and `previousEntryId` when they attach after an entry the client holds; its absence marks entries that attach to nothing. Each entry states `entryId`, the signed `changeMonths`, the resulting `balanceMonths`, `balanceStartTs`, `balanceAnchorTs`, and `balanceBadgeType`, `wasPausedSince` on the entry ending a pause, `createdAt`, and `entryType` — `credit`: `payment {invoiceId?}`, `code`, `charge {chargeId}`, `support`, `transferIn {fromPurchaseKey}`, `opening`; `debit`: `refund`, `upgrade {toPurchaseKey}`, `transferOut {toPurchaseKey}`, `support`, `badge`, `lapse`. A code grant is `code` rather than `payment` with no `invoiceId`: the invoice of a code belongs to whoever bought it, and the redeemer's ledger must never reference it. A `charge` credit is dated to the store's billing period start, its `createdAt`, so coverage follows the store's periods and the entry is its grant re-run at that time. An unknown type is stored as received and decoded after an app upgrade. -`subscription` — present exactly when a store subscription funds the purchase, as the service last read the store: `renewing`; `cancelled`, auto-renewal is off and the paid period still runs; `billingRetry`, a charge failed and the store is retrying it; `ended`, no charge will come. A client reads any other value as not ended. +`subscription` — present exactly when a store subscription funds the purchase, as the service last read the store, or funded it before `transferBadge` moved it away, then always `ended`: `renewing`; `cancelled`, auto-renewal is off and the paid period still runs; `billingRetry`, a charge failed and the store is retrying it; `ended`, no charge will come. A client reads any other value as not ended. `balance` — `lastEntry`, the client's last entry, asserting the position and the months it believes it holds.