mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 19:27:55 +00:00
deploy: 9696a71b91
This commit is contained in:
1 parent
29a7331cf4
commit
e37318cb06
5 files changed
+110
-79
No files matched your search
@@ -890,7 +890,7 @@ window.addEventListener('click',(e)=>{
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<div><p>Revision 1, 2026-09-18</p>
|
||||
<div><p>Revision 2, 2026-10-22</p>
|
||||
<h1 id="simplex-supporter-badges" tabindex="-1">SimpleX Supporter Badges</h1>
|
||||
<h2 id="table-of-contents" tabindex="-1">Table of contents</h2>
|
||||
<ul>
|
||||
@@ -942,6 +942,7 @@ window.addEventListener('click',(e)=>{
|
||||
</ul>
|
||||
<h3 id="what-a-badge-discloses" tabindex="-1">What a badge discloses</h3>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<h3 id="comparison-with-signal-badges" tabindex="-1">Comparison with Signal badges</h3>
|
||||
<p>Signal sells donation badges using <a href="https://eprint.iacr.org/2019/1416.pdf">receipt credentials</a>. The client obtains a credential against a payment without revealing its account, and then presents the credential once to the account server, which records its serial number, level and expiry against the account and attaches the badge to the account. The payment is not linked to the account cryptographically. The same mechanism is used for Signal's paid backups.</p>
|
||||
@@ -1002,7 +1003,7 @@ window.addEventListener('click',(e)=>{
|
||||
<td>The end of the following Monday</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Issuance and presentation on different days</td>
|
||||
<td>Renewal and presentation on different days</td>
|
||||
<td>No</td>
|
||||
<td>Yes</td>
|
||||
</tr>
|
||||
@@ -1017,7 +1018,7 @@ window.addEventListener('click',(e)=>{
|
||||
<ul>
|
||||
<li>A badge does not restrict anything that is available today: the defaults are unchanged, and a badge only raises them.</li>
|
||||
<li>A badge does not create an identity: it has no persistent identifier, it is not linked across conversations, and an incognito profile does not show it.</li>
|
||||
<li>A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for.</li>
|
||||
<li>A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for. The exception is an app store subscription, which Restore moves to a new purchase key of whichever profile presents its store receipt, and moves again only once the last credential of the key it last moved from has expired.</li>
|
||||
<li>A badge does not exempt its holder from the limits a server applies: it lowers the cost of a resource without removing the limit on it.</li>
|
||||
<li>A badge cannot be revoked; credentials are issued for one month at a time instead.</li>
|
||||
</ul>
|
||||
@@ -1059,7 +1060,7 @@ window.addEventListener('click',(e)=>{
|
||||
<p>In the app, the user pays by card, in cryptocurrency, or through the app store. The app requests an invoice from the service, or presents the receipt of the app store, and the service issues the credential once the payment is confirmed.</p>
|
||||
<p>In both cases the app generates the master key and the purchase key pair before the purchase. To redeem a code, the app sends the code and the master key to the service, which issues the credential. A code redeemed a second time with the same purchase key returns its latest credential; a code redeemed with a different purchase key is refused once all its uses are taken.</p>
|
||||
<h3 id="monthly-issuance" tabindex="-1">Monthly issuance</h3>
|
||||
<p>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. Unused months, e.g. if the app was offline, lapse at the next badge issuance.</p>
|
||||
<p>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 before issuing each month, so a new charge funds the next month once the paid months have run out, and a refund or revocation the store reports while they run, as in an annual plan, ends the badge; it also asks at once when the app reports a higher tier, which it does for an App Store raise the device sees before the raised charge expires. The app asks about such a raise, or about a lapsed subscription its store charged again, a bounded number of times each time the device hands the charge over, and again after the next hand-over. Any other App Store change of tier is granted at the first check after the old plan's paid months run out, though Apple bills a raise at once. On Play another badge is a new purchase: the app starts no plan change, and Play's pages offered none for these products when tested; one made there would be a new purchase under a new token, credited only when Restore hands it over (see <a href="/docs/protocol/badges-rpc.html#upgrades">Upgrades</a>). Unused months, e.g. if the app was offline, lapse at the next badge issuance.</p>
|
||||
<p>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.</p>
|
||||
<p>The renewal is split over two days for the same reason. The app requests the next credential on the day before the current one expires, and when the current one expires, it switches to the new one and sends its updated profile to its contacts. In the example above, the app asks the service for the new credential on Monday 19 October and starts showing it on Tuesday 20 October, so an observer of both the request and the profile update cannot correlate them by time.</p>
|
||||
<p>The renewal runs in the background and does not require any action from the user. Recipients accept a badge for seven days after its expiry, and servers for one day, so a renewal delayed by a few days is not visible to contacts.</p>
|
||||
@@ -1094,8 +1095,8 @@ window.addEventListener('click',(e)=>{
|
||||
<ol>
|
||||
<li>A proof discloses no value that links it to another proof or to the purchase.</li>
|
||||
<li>A proof used in one context, whether a session, a conversation or a file, cannot be used in another context.</li>
|
||||
<li>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.</li>
|
||||
<li>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 and the codes issued in the service's managed group or redeemed by its members, and a request about a purchase can be made only by the holder of the purchase key.</li>
|
||||
<li>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 (after a purchase, a redemption, a Restore, or a store subscription resumed after it lapsed) is shown as soon as its credential is issued, so for these the request and the profile update are made together. A higher tier is shown as soon as it is issued too, whether the app reported it or a renewal check granted it, since the subscriber raised it in their store; a lower tier granted at a renewal check is shown when the current credential expires, as a renewal is.</li>
|
||||
<li>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.</li>
|
||||
<li>A credential cannot be forged: the issuer keys are fixed in apps and servers, and the app verifies a credential before storing it.</li>
|
||||
<li>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.</li>
|
||||
</ol>
|
||||
@@ -1108,6 +1109,7 @@ window.addEventListener('click',(e)=>{
|
||||
<li>Issue any credential, or refuse to issue one, as it holds the issuer key.</li>
|
||||
<li>Link a code issued in its managed group to the member who asked for it and to that group, as the group's messages, codes included, are kept in the service's chat database.</li>
|
||||
<li>Link a redemption to a member of its managed group whose profile shows a new badge soon after it.</li>
|
||||
<li>Link the two purchase keys between which Restore moved a store subscription.</li>
|
||||
</ul>
|
||||
<p><em>cannot:</em></p>
|
||||
<ul>
|
||||
@@ -1168,6 +1170,8 @@ window.addEventListener('click',(e)=>{
|
||||
<p>An attacker who obtains the credential and the purchase key can use the badge until it expires and renew it while months remain. There is no revocation, so the loss is limited to the months bought.</p>
|
||||
<p><strong>Interception of a code</strong></p>
|
||||
<p>A code is a bearer secret until its last use is taken; after that, it is refused to any other purchase key.</p>
|
||||
<p><strong>Interception of a store receipt</strong></p>
|
||||
<p>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.</p>
|
||||
<p><strong>A passive network observer</strong></p>
|
||||
<p><em>can:</em></p>
|
||||
<ul>
|
||||
@@ -1182,7 +1186,7 @@ window.addEventListener('click',(e)=>{
|
||||
<ul>
|
||||
<li>Backups will be stored on XFTP servers, and a badge will raise their size and their storage time, as described under <a href="#what-a-badge-grants">What a badge grants</a>.</li>
|
||||
<li>SMP servers and notification servers will verify the proof. A server that verifies it can charge the holder of a badge less for creating queues, file chunks and notification tokens (under the planned proof-of-work scheme, the required effort is divided by a factor configured for each badge type), and can apply higher rate limits and larger quotas.</li>
|
||||
<li>Subscriptions that renew automatically, the transfer of remaining months to a new device, and the pausing of a prepaid badge are planned.</li>
|
||||
<li>The transfer of a badge bought by card or cryptocurrency to a new device, and the pausing of a prepaid badge, are planned.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Revision 1, 2026-09-18
|
||||
Revision 2, 2026-10-22
|
||||
|
||||
# SimpleX Supporter Badges
|
||||
|
||||
@@ -53,6 +53,8 @@ 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 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.
|
||||
|
||||
### Comparison with Signal badges
|
||||
@@ -85,14 +87,14 @@ 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
|
||||
|
||||
- A badge does not restrict anything that is available today: the defaults are unchanged, and a badge only raises them.
|
||||
- A badge does not create an identity: it has no persistent identifier, it is not linked across conversations, and an incognito profile does not show it.
|
||||
- A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for.
|
||||
- A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for. The exception is an app store subscription, which Restore moves to a new purchase key of whichever profile presents its store receipt, and moves again only once the last credential of the key it last moved from has expired.
|
||||
- A badge does not exempt its holder from the limits a server applies: it lowers the cost of a resource without removing the limit on it.
|
||||
- A badge cannot be revoked; credentials are issued for one month at a time instead.
|
||||
|
||||
@@ -147,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. 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 before issuing each month, so a new charge funds the next month once the paid months have run out, and a refund or revocation the store reports while they run, as in an annual plan, ends the badge; it also asks at once when the app reports a higher tier, which it does for an App Store raise the device sees before the raised charge expires. The app asks about such a raise, or about a lapsed subscription its store charged again, a bounded number of times each time the device hands the charge over, and again after the next hand-over. Any other App Store change of tier is granted at the first check after the old plan's paid months run out, though Apple bills a raise at once. On Play another badge is a new purchase: the app starts no plan change, and Play's pages offered none for these products when tested; one made there would be a new purchase under a new token, credited only when Restore hands it over (see [Upgrades](https://simplex.chat/docs/protocol/badges-rpc.md#upgrades)). 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.
|
||||
|
||||
@@ -202,8 +204,8 @@ 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.
|
||||
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 and the codes issued in the service's managed group or redeemed by its members, and a request about a purchase can be made only by the holder of the purchase key.
|
||||
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 (after a purchase, a redemption, a Restore, or a store subscription resumed after it lapsed) is shown as soon as its credential is issued, so for these the request and the profile update are made together. A higher tier is shown as soon as it is issued too, whether the app reported it or a renewal check granted it, since the subscriber raised it in their store; a lower tier granted 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.
|
||||
|
||||
@@ -219,6 +221,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/
|
||||
- Issue any credential, or refuse to issue one, as it holds the issuer key.
|
||||
- Link a code issued in its managed group to the member who asked for it and to that group, as the group's messages, codes included, are kept in the service's chat database.
|
||||
- Link a redemption to a member of its managed group whose profile shows a new badge soon after it.
|
||||
- Link the two purchase keys between which Restore moved a store subscription.
|
||||
|
||||
*cannot:*
|
||||
|
||||
@@ -289,6 +292,10 @@ An attacker who obtains the credential and the purchase key can use the badge un
|
||||
|
||||
A code is a bearer secret until its last use is taken; after that, it is refused to any other purchase key.
|
||||
|
||||
**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. 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**
|
||||
|
||||
*can:*
|
||||
@@ -304,4 +311,4 @@ A code is a bearer secret until its last use is taken; after that, it is refused
|
||||
|
||||
- Backups will be stored on XFTP servers, and a badge will raise their size and their storage time, as described under [What a badge grants](#what-a-badge-grants).
|
||||
- SMP servers and notification servers will verify the proof. A server that verifies it can charge the holder of a badge less for creating queues, file chunks and notification tokens (under the planned proof-of-work scheme, the required effort is divided by a factor configured for each badge type), and can apply higher rate limits and larger quotas.
|
||||
- Subscriptions that renew automatically, the transfer of remaining months to a new device, and the pausing of a prepaid badge are planned.
|
||||
- The transfer of a badge bought by card or cryptocurrency to a new device, and the pausing of a prepaid badge, are planned.
|
||||
@@ -896,8 +896,8 @@ window.addEventListener('click',(e)=>{
|
||||
<p>Service RPC (<code>plans/2026-07-22-service-rpc-chat.md</code>, branch <code>rpc</code>): the request travels in <code>APISendServiceRequest.request</code>, the response in <code>CRServiceResponse.responseData</code>; one response per request; per-call timeout.</p>
|
||||
<p>A request is an envelope: <code>version</code> — the client's protocol version; <code>purchaseKey</code>; <code>request</code> — the command, discriminated on <code>type</code>. Responses are discriminated on <code>type</code>. The service is deployed ahead of app releases, answers within the client's <code>version</code>, and rejects clients older than it supports with <code>unsupported_version</code>.</p>
|
||||
<h2 id="identity" tabindex="-1">Identity</h2>
|
||||
<p>Each purchase runs under a fresh Ed25519 key pair; <code>purchaseKey</code> is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below. <code>getBadgeCatalog</code> may omit <code>purchaseKey</code>: unsigned, it returns the catalog alone; signed, its response adds the purchase's <code>badgeStatement</code> — 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 <code>purchaseKey</code> that differs from it with <code>bad_request</code>.</p>
|
||||
<p>A purchase record is created by <code>redeemBadgeCode</code>, by <code>getBadgeInvoice</code>, or by <code>purchaseBadge</code> funded with <code>apple</code>, <code>google</code>, or <code>receipt</code>. Those commands accept a key the service holds no record of — on a first purchase it always will. Every other command answers <code>unknown_purchase_key</code> for such a key.</p>
|
||||
<p>Each purchase runs under a fresh Ed25519 key pair; <code>purchaseKey</code> 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 <code>transferBadge</code>, whose old purchase's ledger names the new key. <code>getBadgeCatalog</code> may omit <code>purchaseKey</code>: unsigned, it returns the catalog alone; signed, its response adds the purchase's <code>badgeStatement</code> — 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 <code>purchaseKey</code> that differs from it with <code>bad_request</code>.</p>
|
||||
<p>A purchase record is created by <code>redeemBadgeCode</code>, by <code>getBadgeInvoice</code> (not built), by <code>purchaseBadge</code> funded with <code>apple</code> or <code>google</code> (or <code>receipt</code>, post-MVP), or by <code>transferBadge</code>. Those commands accept a key the service holds no record of — on a first purchase it always will, and <code>transferBadge</code> requires one. Every other command answers <code>unknown_purchase_key</code> for such a key.</p>
|
||||
<h2 id="idempotency" tabindex="-1">Idempotency</h2>
|
||||
<p>A timeout hides the outcome, so the client repeats the identical signed request at its next trigger, never on a poll timer.</p>
|
||||
<ul>
|
||||
@@ -905,44 +905,47 @@ window.addEventListener('click',(e)=>{
|
||||
<li><code>redeemBadgeCode</code> — a code already redeemed by the signing key returns its latest <code>badgeCredential</code> and writes nothing; once other keys have taken all its uses, <code>code_used</code>. The client must therefore keep the key it first signed with, or a retry cannot be recognised.</li>
|
||||
<li><code>purchaseBadge</code> — a payment already credited returns the same <code>badgeCredential</code> and writes nothing; a store payment credited to another key, <code>receipt_used</code>.</li>
|
||||
<li><code>upgradeBadgeSubscription</code> — evidence already applied returns the same result and writes nothing.</li>
|
||||
<li><code>transferBadge</code> — a payment already moved to the signing key returns the same <code>badgeCredential</code>, without asking the store and without the move limit.</li>
|
||||
<li><code>issueBadge</code> — repeated within an issued period, returns the cached credential and writes nothing.</li>
|
||||
<li><code>purchaseBadge</code> with the recovery <code>receipt</code> — presented again by the same key it returns the same result; presented by another key, <code>receipt_used</code>.</li>
|
||||
</ul>
|
||||
<h2 id="commands" tabindex="-1">Commands</h2>
|
||||
<p>The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the <code>sundayAfter</code> of the period issued (<code>endOfMondayAfter</code> in code), shared by every credential of that week; <code>badgeExtra</code> is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries <code>badgeRequest</code>. <code>upgradeBadgeSubscription</code> still has the field in the schema; that is wrong and it must be dropped when the command is implemented.</p>
|
||||
<p>The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the <code>sundayAfter</code> of the period issued (<code>endOfMondayAfter</code> in code), shared by every credential of that week; <code>badgeExtra</code> is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries <code>badgeRequest</code>.</p>
|
||||
<ul>
|
||||
<li><code>getBadgeCatalog</code> → <code>badgeCatalog</code> — the prices and offers; signed, also the purchase's <code>badgeStatement</code>. Store builds never send it: prices come from the store and SKUs from app config.</li>
|
||||
<li><code>getBadgeInvoice</code> → <code>badgeInvoice</code> — prices the purchase for <code>badgeInfo</code> and <code>paymentVia</code> (<code>card</code> — Stripe; <code>crypto</code> — btc, xmr). The response holds the generic <code>invoice</code> — <code>invoiceId</code>, <code>price</code>, <code>discount</code>, the upgrade <code>credit</code>, <code>amount</code> = price − discount − credit, <code>currency</code>, <code>expiresAt</code>, and <code>paymentTo</code> (<code>url</code> for card; <code>address</code> and <code>cryptoAmount</code> for crypto) — beside the badge part, <code>badgeType</code> and <code>months</code>. <code>priceId</code> pins the price the client displayed; <code>offerId</code> selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: <code>deprecated</code> is still accepted, <code>disabled</code> is rejected; a badge type with no active price yields <code>product_unavailable</code>.</li>
|
||||
<li><code>redeemBadgeCode</code> → <code>badgeCredential</code> — redeems a code, records the credit, and issues the first credential, in one round trip. It carries <code>masterKey</code> and <code>code</code> and no <code>badgeRequest</code>: a code states no tier and no expiry, so the credential is what reports them. Errors: <code>code_invalid</code> for an unknown or malformed code, <code>code_used</code> when other keys have taken all its uses (a code has one use unless issued with more), <code>code_expired</code> past a redemption deadline.</li>
|
||||
<li><code>purchaseBadge</code> → <code>badgeCredential</code> — verifies the funding (<code>apple</code> JWS offline; <code>google</code> product id and token via the Publisher API; <code>invoice</code> against webhook-confirmed settlement, <code>payment_pending</code> until it lands; <code>receipt</code>), records the credit, and issues the first credential, in one round trip. It carries <code>masterKey</code> and no <code>badgeRequest</code>. The response <code>receipt</code> is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none.
|
||||
<ul>
|
||||
<li>A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. A receipt already credited to the signing key is answered from the record, without asking the store.</li>
|
||||
<li>Errors: <code>receipt_invalid</code>, <code>test_purchase</code>, <code>payment_pending</code>, <code>provider_unavailable</code> — see the classes below; <code>receipt_used</code> when another key was credited with it; <code>product_unavailable</code> for a product this service does not price; <code>provider_not_configured</code> when this deployment has no verifier for the store. <code>receipt_invalid</code>, <code>test_purchase</code> and <code>receipt_used</code> are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger.</li>
|
||||
<li><strong>Which class an answer belongs to.</strong> The two mistakes do not cost the same, so when it is not certain, answer unreachable. <code>receipt_invalid</code> tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good.
|
||||
<li>A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. An App Store auto-renewable subscription is claimed by its <code>originalTransactionId</code>, which every renewal keeps, so all its charges fund one purchase; a Play subscription keeps its token across renewals already. A receipt already credited to the signing key is answered from the record, without asking the store.</li>
|
||||
<li><strong>Subscriptions.</strong> A Play subscription is verified by reading the subscription itself, not by the one-time purchase read; an App Store one by its JWS offline, as any transaction, and then by reading the subscription's status. The latest charge the store reports is credited as a <code>charge</code> credit for the months of the plan it was for, dated to the store's billing period start, and the response carries <code>subscription</code>. A charge is credited only while its period runs, if it is not revoked, not bought with an offer (on the App Store any offer type; on Play a free-trial or introductory-price phase), for a plan this service sells, while the subscription has not ended, and while the months its plan grants from its period start still run (Play derives that start by clipping at month ends, so a period's last days can fund nothing). Otherwise nothing is written. No record yet bounds how often the store is asked, so a subscription is asked about at most once an hour on this path, an App Store one per original transaction id and a Play one per token, in an hour not shared with <code>transferBadge</code>'s: presented to <code>purchaseBadge</code> again inside that hour, by any key (and an App Store one as any of its transactions), it is answered <code>payment_pending</code> with the time left, and an answer whose <code>retryAfter</code> would be shorter carries the hour. Across all requests, this path asks Play about at most 300 subscriptions a minute (one it credits is read once more to acknowledge it): a Play subscription presented when none is left is answered <code>rate_limited</code> with the seconds left in the minute, and is not held off. The answers: <code>payment_pending</code> with an hour for a period that has not started yet or a store that reports the payment pending; <code>receipt_invalid</code> for a Play subscription that has ended, since Play issues a new token when it is bought again; <code>payment_pending</code> with a day for everything a later charge on the same receipt could still change — a revoked charge, one in an offer, no charge yet, a period over while the store still retries, a charge whose months from its period start have already run out, a plan this service does not sell, and an App Store subscription that has ended, which a resubscription under the same original id would continue. A charge whose period does not end after it starts is answered <code>internal</code>, and a deployment that cannot read a store's subscriptions answers <code>provider_not_configured</code>.</li>
|
||||
<li>Errors: <code>receipt_invalid</code>, <code>test_purchase</code>, <code>payment_pending</code>, <code>provider_unavailable</code> — see the classes below; <code>rate_limited</code> for a Play subscription when this path's Play checks for the minute are used; <code>receipt_used</code> when another key was credited with it; <code>product_unavailable</code> for a product this service does not price; <code>provider_not_configured</code> when this deployment has no verifier for the store. <code>receipt_invalid</code>, <code>test_purchase</code> and <code>receipt_used</code> are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger.</li>
|
||||
<li><strong>Which class an answer belongs to.</strong> The two mistakes do not cost the same, so when it is not certain, answer unreachable. <code>receipt_invalid</code> tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good, but for an App Store subscription charge, which stops holding the profile a day after its period ends.
|
||||
<ul>
|
||||
<li><strong>Terminal</strong>, <code>receipt_invalid</code> — only a verdict no later attempt could change: the store answered about this purchase and cannot change its mind, or the evidence can never verify. Anything that might answer differently later — the store's lag, an outage, this service's own misconfiguration or bug, a shape of evidence this service does not know yet — is one of the other classes.</li>
|
||||
<li><strong>Terminal and named</strong>, <code>test_purchase</code> — the store vouched for a purchase that cost nothing: Apple's <code>Sandbox</code>, or Play's <code>purchaseType</code> test. It is separated from <code>receipt_invalid</code> for one reason: the device that made the purchase already knows it was a test one, so naming it reveals nothing a forger could iterate on, and it lets the app say why instead of reporting a generic failure. Every other refusal stays lumped together for exactly that reason.</li>
|
||||
<li><strong>Pending</strong>, <code>payment_pending</code> — a real purchase the store has not settled.</li>
|
||||
<li><strong>Unreachable</strong>, <code>provider_unavailable</code> — the store was not asked or did not answer.</li>
|
||||
<li><strong>Internal</strong>, <code>internal</code> — this service's own failure, never the store's answer.</li>
|
||||
<li><strong>App Store.</strong> Apple is verified offline, so it has no "not yet".
|
||||
<li><strong>App Store.</strong> Apple's receipt is verified offline, so it has no "not yet"; a subscription's status is then read from the App Store Server API, which can fail to answer.
|
||||
<ul>
|
||||
<li><code>receipt_invalid</code>: a JWS that is not three base64url parts or names no <code>transactionId</code>; an <code>x5c</code> certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a <code>revocationDate</code>, refunded or revoked.</li>
|
||||
<li><code>test_purchase</code>: refused by the service after the verifier vouched for it, a <code>Sandbox</code> transaction.</li>
|
||||
<li><code>internal</code>: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's <code>bundleId</code>, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name <code>ES256</code> or carries no <code>x5c</code> of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown <code>environment</code>; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1.</li>
|
||||
<li><code>receipt_invalid</code>: a JWS that is not three base64url parts or names no <code>transactionId</code>, or, for an auto-renewable subscription, no <code>originalTransactionId</code>; an <code>x5c</code> certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a <code>revocationDate</code>, refunded or revoked; and a subscription status whose transaction is family-shared.</li>
|
||||
<li><code>test_purchase</code>: refused by the service after the verifier vouched for it, a <code>Sandbox</code> transaction, and a subscription status from the <code>Sandbox</code> environment.</li>
|
||||
<li><code>provider_unavailable</code>, for a subscription's status only: network failure or timeout, and every error response but 401 and 403 — a 404 included, which Apple answers for an original transaction id its environment does not hold — 429 and 5xx among them; and an answer that names no such subscription, as after a Sandbox purchase history is cleared, which is no more certain than that 404.</li>
|
||||
<li><code>internal</code>: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's <code>bundleId</code>, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name <code>ES256</code> or carries no <code>x5c</code> of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown <code>environment</code>; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1. For a subscription's status: Apple refusing this service's API token or key (401, 403); an original transaction id that is not all digits; an answer over a mebibyte, or whose list of subscriptions or entry for this subscription cannot be read, an unreadable entry for another subscription being ignored; a signed transaction or renewal info that fails any check above (Apple answered it and no client presented it, so it is never <code>receipt_invalid</code>), is not a subscription transaction or renewal info, names another app's bundle id or another subscription, or an unknown environment; and a status this service does not know.</li>
|
||||
<li><code>product_unavailable</code>: a product this service does not price — the verifier vouched for the transaction either way.</li>
|
||||
<li><code>provider_not_configured</code>: the deployment has no <code>[apple]</code> section.</li>
|
||||
<li><code>provider_not_configured</code>: the deployment has no <code>[apple]</code> section, and, for a subscription, an <code>[apple]</code> section without an App Store Server API key.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Google Play.</strong> Only a purchase record is a verdict.
|
||||
<ul>
|
||||
<li><code>receipt_invalid</code>: <code>purchaseState</code> canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one.</li>
|
||||
<li><code>test_purchase</code>: refused by the service after the verifier vouched for it, <code>purchaseType</code> test, a license tester's purchase.</li>
|
||||
<li><code>payment_pending</code>: <code>purchaseState</code> pending.</li>
|
||||
<li><code>provider_unavailable</code>: network failure or timeout, and every error response from <code>purchases.products.get</code> but 401 and 403 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400 or 410 calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away.</li>
|
||||
<li><code>internal</code>: Google refusing this service's credentials or permissions — a 401 or 403 from <code>purchases.products.get</code>, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a <code>purchaseState</code> this service does not know; and a <code>purchaseType</code> other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase.</li>
|
||||
<li><code>receipt_invalid</code>: <code>purchaseState</code> canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one; and a 410 from <code>purchases.subscriptionsv2.get</code>, a subscription that expired too long ago to read, on a first purchase (a renewal check answers it as ended).</li>
|
||||
<li><code>test_purchase</code>: refused by the service after the verifier vouched for it, <code>purchaseType</code> test, a license tester's purchase, and a subscription with <code>testPurchase</code>.</li>
|
||||
<li><code>payment_pending</code>: <code>purchaseState</code> pending, and <code>SUBSCRIPTION_STATE_PENDING</code>.</li>
|
||||
<li><code>provider_unavailable</code>: network failure or timeout, and every error response from <code>purchases.products.get</code> or <code>purchases.subscriptionsv2.get</code> except 401, 403 and a subscription check's 410 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400, or a 410 from <code>purchases.products.get</code>, calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away.</li>
|
||||
<li><code>internal</code>: Google refusing this service's credentials or permissions — a 401 or 403 from <code>purchases.products.get</code> or <code>purchases.subscriptionsv2.get</code>, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a <code>purchaseState</code> or <code>subscriptionState</code> this service does not know; a subscription that is not pending with more than one line item, or with none unless it has ended (<code>SUBSCRIPTION_STATE_EXPIRED</code> or <code>SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED</code>); a charge without its <code>expiryTime</code>, product or base plan, and a charge for a base plan this service sells under another product; and a <code>purchaseType</code> other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase.</li>
|
||||
<li><code>product_unavailable</code> and <code>provider_not_configured</code> as for the App Store, the latter without a <code>[google]</code> section.</li>
|
||||
<li>Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — <code>provider_unavailable</code> when Play does not answer, <code>internal</code> when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers <code>internal</code> to every Play purchase the app has not already acknowledged itself.</li>
|
||||
<li>Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed; a subscription is acknowledged with <code>purchases.subscriptions.acknowledge</code> before its first charge is credited. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — <code>provider_unavailable</code> when Play does not answer, <code>internal</code> when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers <code>internal</code> to every Play purchase the app has not already acknowledged itself.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -950,27 +953,33 @@ window.addEventListener('click',(e)=>{
|
||||
<li>Funding by <code>receipt</code> is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as <code>debit(transferOut)</code> on the source and <code>credit(transferIn)</code> on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><code>upgradeBadgeSubscription</code> → <code>badgeCredential</code> — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. Its <code>badgeRequest</code> is to be dropped (see above).</li>
|
||||
<li><code>issueBadge</code> → <code>badgeCredential</code> — issues the next period from the balance, the only source of issuance. It carries <code>balance</code> alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the <code>sundayAfter</code> of the period issued. The ledger is advanced first; the credential is signed before the <code>debit(badge)</code> and issuance rows are written, in one transaction. An exhausted balance yields no <code>credential</code>; the <code>statement</code> shows why. Issuing on a paused badge resumes it (model 2.13).</li>
|
||||
<li><code>upgradeBadgeSubscription</code> → <code>badgeCredential</code> — an App Store subscription raised to a higher tier, on the same key. The client sends it when the device sees a transaction of the purchase's own subscription for a higher tier, bought after the purchase's last charge credit in its ledger and not expired (see Upgrades); it carries <code>payment</code>, that transaction, and <code>balance</code>, and makes the service ask the store now rather than at the next renewal check. It is refused before the store is asked when the transaction does not verify (<code>receipt_invalid</code>), when it is of no subscription product, of another subscription, of a purchase no App Store subscription funds, or from Play (<code>bad_request</code>), and for an unknown key (<code>unknown_purchase_key</code>). The store is asked at most once an hour per subscription, an hour shared with the renewal checks. A transaction of the tier the ledger already holds is answered from the record. Otherwise the store is asked and its latest charge is granted as a renewal check grants it, of whatever tier, and the answer is the new tier's credential once that tier is granted; until then the answer is <code>payment_pending</code> with a <code>retryAfter</code> to the next allowed check, never the old tier's credential. The client retries it a bounded number of times for each hand-over of the transaction, then sets it aside until the device hands it over again. The tier granted is the store's; the transaction only names the subscription and the tier to wait for, so a lower tier sent here is granted only once the store charges it, at its renewal.</li>
|
||||
<li><code>transferBadge</code> → <code>badgeCredential</code> — Restore on another device or profile: it carries <code>masterKey</code> and <code>payment</code>, the store receipt of a subscription credited to another key, and moves the subscription to the signing key, which must hold no purchase yet (<code>bad_request</code>). The store is asked first, at most once an hour per subscription as for <code>purchaseBadge</code> but in an hour of its own, so a Restore right after the purchase is not held off by the purchase's check. An App Store transaction other than the subscription's latest is answered <code>payment_pending</code>, so an older receipt stops moving it once the store charges again. The store's current charge must be one a first purchase could be credited, and is answered as for a first purchase when it is not. The old purchase is lapsed and debited <code>transferOut {toPurchaseKey}</code> of its remaining months, even none, and is answered <code>subscription = ended</code> with no <code>credential</code> from then on; the new purchase is credited that charge from its period start, as a first purchase is, and the month due is issued. A payment moves again only once the credential issued to the key it last moved from has expired (<code>transfer_limited</code>): a store receipt is not a one-time secret, so this keeps each paid month with at most two keys. A payment credited to no key is credited as by <code>purchaseBadge</code>. A one-time purchase is not moved (<code>receipt_used</code>): its months were issued at purchase.</li>
|
||||
<li><code>issueBadge</code> → <code>badgeCredential</code> — issues the next period from the balance, the only source of issuance. It carries <code>balance</code> alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the <code>sundayAfter</code> of the period issued. The ledger is advanced first; the credential is signed before the <code>debit(badge)</code> and issuance rows are written, in one transaction. An exhausted balance yields no <code>credential</code>; the <code>statement</code> shows why. Issuing on a paused badge resumes it (model 2.13).
|
||||
<ul>
|
||||
<li><strong>Subscriptions.</strong> The service asks the store whenever a month is due, at most once an hour per subscription whether or not the store answers, and answers requests in between from what it last read; a request with no month due is answered as above, without asking. While the paid coverage runs, as an annual plan's does, a store that reports the subscription ended, or the charge it was granted for revoked, ends it: the months not yet issued are debited as <code>refund</code>, at the time of the request after a lapse of those that ran out, and the answer is <code>subscription = ended</code> with no <code>credential</code> — also when the service already read it ended within the hour. Any other answer issues the month due as above, a store that fails or does not answer included, and a new charge it reports is recorded and granted only once the coverage ends. Once coverage has ended, a charge it can grant, by the rule <code>purchaseBadge</code> applies to a first charge but never twice, is granted at the store's period start, or at the ledger's last row when that is later, so rows are never dated before the row they follow, after a lapse of what ran out before it, and the month due is issued. A charge of another badge type is granted as that type: what remains of the old run is debited as <code>upgrade</code> naming the purchase's own key, the new run starts at that date, and the purchase's badge type follows it. Otherwise no ledger row is written: the charge, if any, and the state are recorded, and the answer carries no new <code>credential</code> and the <code>subscription</code> the store reported. A store that does not answer is answered <code>provider_unavailable</code> with a <code>retryAfter</code> of the hour. Every <code>badgeCredential</code> answer for a subscription purchase carries <code>subscription</code>. A development deployment may ask its store at every request, during coverage too, and bank the months of a charge it can grant, issued once coverage reaches them, and ends it as above; a real store is asked at every request only by a development deployment that also credits test purchases.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><code>pauseBadge</code> (post-MVP) → <code>badgeCredential</code> — suspends issuance and lapse (model 2.13).</li>
|
||||
</ul>
|
||||
<h2 id="upgrades" tabindex="-1">Upgrades</h2>
|
||||
<p>Always a new purchase under a new key, except store subscriptions, where the store owns the change.</p>
|
||||
<p>Always a new purchase under a new key, except App Store subscriptions, where the store owns the change and the purchase keeps its key.</p>
|
||||
<ul>
|
||||
<li>Non-store: <code>getBadgeInvoice.upgrade</code> — <code>fromPurchaseKey</code>, the old purchase's <code>receipt</code>, <code>receiptSignature</code> binding the old key to the new, and the asserted old <code>balance</code>. The invoice returns the conversion <code>credit</code>; settlement records <code>debit(upgrade)</code> on the old purchase and the credit on the new.</li>
|
||||
<li>Store one-time: an upgrade SKU at a fixed discounted price; <code>purchaseBadge.upgrade</code> — <code>fromPurchaseKey</code>, <code>receipt</code>, <code>receiptSignature</code> — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU.</li>
|
||||
<li>Store subscription, app-led: the native subscription-group flow (Apple — immediate, with the store's prorated refund; Google — per replacement mode), then <code>upgradeBadgeSubscription</code> with the new evidence.</li>
|
||||
<li>Store subscription, sheet-led, and every downgrade: the client sends nothing — the service discovers the change from provider state and notifications, and each renewal credits months of the charged badge type.</li>
|
||||
<li>Non-store (not built): <code>getBadgeInvoice.upgrade</code> — <code>fromPurchaseKey</code>, the old purchase's <code>receipt</code>, <code>receiptSignature</code> binding the old key to the new, and the asserted old <code>balance</code>. The invoice returns the conversion <code>credit</code>; settlement records <code>debit(upgrade)</code> on the old purchase and the credit on the new.</li>
|
||||
<li>Store one-time (not built: <code>purchaseBadge</code> with <code>upgrade</code> is answered <code>bad_request</code>): an upgrade SKU at a fixed discounted price; <code>purchaseBadge.upgrade</code> — <code>fromPurchaseKey</code>, <code>receipt</code>, <code>receiptSignature</code> — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU.</li>
|
||||
<li>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 <code>upgradeBadgeSubscription</code> when the device sees its transaction bought after the purchase's last charge credit and not yet expired, however late the device sees it, and is granted as soon as the store reports the charge; each hand-over is retried a bounded number of times, and the next one holds it again. Every other change — every lower tier, which Apple charges from the next renewal, a change of plan length, and a higher tier whose transaction has expired or is no newer than the last charge credit — 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.</li>
|
||||
<li>Play subscription: the client never starts a plan change. Each badge is its own Play product, with a monthly and an annual base plan, and when tested (2026-10-22) Play's own pages offered neither another badge nor another billing period, so another badge is bought as a new purchase once the current one ends. A plan change made in Play's own pages, should one become possible, 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).</li>
|
||||
</ul>
|
||||
<h2 id="catalog" tabindex="-1">Catalog</h2>
|
||||
<p><code>prices</code> — <code>priceId</code>, <code>badgeType</code>, <code>monthPrice</code>, <code>currency</code>, <code>status</code>, <code>createdAt</code>; <code>offers</code> — <code>offerId</code>, <code>priceId</code>? (absent applies to any price), <code>months</code>, <code>discount</code>, <code>status</code>, <code>createdAt</code>. An offer states a discount, as free months or a percentage; a duration without one is priced at <code>months × monthPrice</code>. Repricing appends a price and deprecates the old, which is still accepted at invoice creation; deprecated prices and offers are sent so that a refresh cannot remove what the client pinned, and disabled ones are omitted. Rendering is app-driven — tiers and durations come from app resources, and one without a price is shown disabled.</p>
|
||||
<h2 id="statement-and-balance" tabindex="-1">Statement and balance</h2>
|
||||
<p>The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. Month boundaries are counted from <code>balanceAnchorTs</code>, the start of the current run of months, and not from <code>balanceStartTs</code> — counting from the moving start would compound the day-of-month clipping of a short month, so a run beginning 31 January would reach 28 February and never return to the 31st. The service sets a new anchor only where a lapsed run restarts; months granted while coverage still runs extend it on its existing anchor.</p>
|
||||
<p><code>statement</code> — <code>entries</code>, and <code>previousEntryId</code> when they attach after an entry the client holds; its absence marks entries that attach to nothing. Each entry states <code>entryId</code>, the signed <code>changeMonths</code>, the resulting <code>balanceMonths</code>, <code>balanceStartTs</code>, <code>balanceAnchorTs</code>, and <code>balanceBadgeType</code>, <code>wasPausedSince</code> on the entry ending a pause, <code>createdAt</code>, and <code>entryType</code> — <code>credit</code>: <code>payment {invoiceId?}</code>, <code>code</code>, <code>charge {chargeId}</code>, <code>support</code>, <code>transferIn {fromPurchaseKey}</code>, <code>opening</code>; <code>debit</code>: <code>refund</code>, <code>upgrade {toPurchaseKey}</code>, <code>transferOut {toPurchaseKey}</code>, <code>support</code>, <code>badge</code>, <code>lapse</code>. A code grant is <code>code</code> rather than <code>payment</code> with no <code>invoiceId</code>: the invoice of a code belongs to whoever bought it, and the redeemer's ledger must never reference it. An unknown type is stored as received and decoded after an app upgrade.</p>
|
||||
<p>The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. Month boundaries are counted from <code>balanceAnchorTs</code>, the start of the current run of months, and not from <code>balanceStartTs</code> — counting from the moving start would compound the day-of-month clipping of a short month, so a run beginning 31 January would reach 28 February and never return to the 31st. The service sets a new anchor only where a run restarts: one that has lapsed, or a grant of another badge type, which starts a run of its own; months of the same type granted while coverage still runs extend it on its existing anchor.</p>
|
||||
<p><code>statement</code> — <code>entries</code>, and <code>previousEntryId</code> when they attach after an entry the client holds; its absence marks entries that attach to nothing. Each entry states <code>entryId</code>, the signed <code>changeMonths</code>, the resulting <code>balanceMonths</code>, <code>balanceStartTs</code>, <code>balanceAnchorTs</code>, and <code>balanceBadgeType</code>, <code>wasPausedSince</code> on the entry ending a pause, <code>createdAt</code>, and <code>entryType</code> — <code>credit</code>: <code>payment {invoiceId?}</code>, <code>code</code>, <code>charge {chargeId}</code>, <code>support</code>, <code>transferIn {fromPurchaseKey}</code>, <code>opening</code>; <code>debit</code>: <code>refund</code>, <code>upgrade {toPurchaseKey}</code>, <code>transferOut {toPurchaseKey}</code>, <code>support</code>, <code>badge</code>, <code>lapse</code>. A code grant is <code>code</code> rather than <code>payment</code> with no <code>invoiceId</code>: the invoice of a code belongs to whoever bought it, and the redeemer's ledger must never reference it. A <code>charge</code> credit is dated to the store's billing period start, its <code>createdAt</code>, so coverage follows the store's periods, or to the previous entry's <code>createdAt</code> when that is later, so dates never decrease; the entry is its grant re-run at that time. An unknown type is stored as received and decoded after an app upgrade.</p>
|
||||
<p><code>subscription</code> — present exactly when a store subscription funds the purchase, as the service last read the store, or funded it before <code>transferBadge</code> moved it away, then always <code>ended</code>: <code>renewing</code>; <code>cancelled</code>, auto-renewal is off and the paid period still runs; <code>billingRetry</code>, a charge failed and the store is retrying it; <code>ended</code>, no charge will come. A client reads any other value as not ended.</p>
|
||||
<p><code>balance</code> — <code>lastEntry</code>, the client's last entry, asserting the position and the months it believes it holds.</p>
|
||||
<p>An assertion that names an entry the service holds is a prefix: the service proceeds and returns what follows it. Otherwise the service heals its own ledger first — provider evidence for charges, time for lapses — proceeds, and returns either the complete history or one <code>opening</code> credit. An <code>opening</code> entry is an absolute restatement: the ledger is reset to the amount it states, without relation to the preceding entry, which also serves a new device and, later, the discarding of old history into a brought-forward balance.</p>
|
||||
<h2 id="errors" tabindex="-1">Errors</h2>
|
||||
<p><code>retryAfter</code> marks the transient codes: <code>payment_pending</code>, <code>provider_unavailable</code>, <code>rate_limited</code>. <code>offer_disabled</code> calls for a catalog refresh. <code>code_invalid</code> covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; <code>code_used</code> — all its uses taken by other keys; <code>code_expired</code> — past its redemption deadline. <code>receipt_invalid</code> covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; <code>test_purchase</code> is named apart from them because the device that bought it already knows; <code>receipt_used</code> — credited to another key; <code>provider_not_configured</code> — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command.</p>
|
||||
<p><code>retryAfter</code> marks the transient codes: <code>payment_pending</code>, <code>provider_unavailable</code>, <code>rate_limited</code>. <code>offer_disabled</code> calls for a catalog refresh. <code>code_invalid</code> covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; <code>code_used</code> — all its uses taken by other keys; <code>code_expired</code> — past its redemption deadline. <code>receipt_invalid</code> covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; <code>test_purchase</code> is named apart from them because the device that bought it already knows; <code>receipt_used</code> — credited to another key; <code>transfer_limited</code> — the subscription moved from a key whose credential is still valid, with no <code>retryAfter</code>; <code>provider_not_configured</code> — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command.</p>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
+33
-26
@@ -10,9 +10,9 @@ A request is an envelope: `version` — the client's protocol version; `purchase
|
||||
|
||||
## Identity
|
||||
|
||||
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. `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`.
|
||||
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`, or by `purchaseBadge` funded with `apple`, `google`, or `receipt`. Those commands accept a key the service holds no record of — on a first purchase it always will. 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
|
||||
|
||||
@@ -22,52 +22,57 @@ A timeout hides the outcome, so the client repeats the identical signed request
|
||||
- `redeemBadgeCode` — a code already redeemed by the signing key returns its latest `badgeCredential` and writes nothing; once other keys have taken all its uses, `code_used`. The client must therefore keep the key it first signed with, or a retry cannot be recognised.
|
||||
- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing; a store payment credited to another key, `receipt_used`.
|
||||
- `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing.
|
||||
- `transferBadge` — a payment already moved to the signing key returns the same `badgeCredential`, without asking the store and without the move limit.
|
||||
- `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing.
|
||||
- `purchaseBadge` with the recovery `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`.
|
||||
|
||||
## Commands
|
||||
|
||||
The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), shared by every credential of that week; `badgeExtra` is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries `badgeRequest`. `upgradeBadgeSubscription` still has the field in the schema; that is wrong and it must be dropped when the command is implemented.
|
||||
The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), shared by every credential of that week; `badgeExtra` is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries `badgeRequest`.
|
||||
|
||||
- `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config.
|
||||
- `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`.
|
||||
- `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when other keys have taken all its uses (a code has one use unless issued with more), `code_expired` past a redemption deadline.
|
||||
- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none.
|
||||
- A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. A receipt already credited to the signing key is answered from the record, without asking the store.
|
||||
- Errors: `receipt_invalid`, `test_purchase`, `payment_pending`, `provider_unavailable` — see the classes below; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store. `receipt_invalid`, `test_purchase` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger.
|
||||
- **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good.
|
||||
- A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. An App Store auto-renewable subscription is claimed by its `originalTransactionId`, which every renewal keeps, so all its charges fund one purchase; a Play subscription keeps its token across renewals already. A receipt already credited to the signing key is answered from the record, without asking the store.
|
||||
- **Subscriptions.** A Play subscription is verified by reading the subscription itself, not by the one-time purchase read; an App Store one by its JWS offline, as any transaction, and then by reading the subscription's status. The latest charge the store reports is credited as a `charge` credit for the months of the plan it was for, dated to the store's billing period start, and the response carries `subscription`. A charge is credited only while its period runs, if it is not revoked, not bought with an offer (on the App Store any offer type; on Play a free-trial or introductory-price phase), for a plan this service sells, while the subscription has not ended, and while the months its plan grants from its period start still run (Play derives that start by clipping at month ends, so a period's last days can fund nothing). Otherwise nothing is written. No record yet bounds how often the store is asked, so a subscription is asked about at most once an hour on this path, an App Store one per original transaction id and a Play one per token, in an hour not shared with `transferBadge`'s: presented to `purchaseBadge` again inside that hour, by any key (and an App Store one as any of its transactions), it is answered `payment_pending` with the time left, and an answer whose `retryAfter` would be shorter carries the hour. Across all requests, this path asks Play about at most 300 subscriptions a minute (one it credits is read once more to acknowledge it): a Play subscription presented when none is left is answered `rate_limited` with the seconds left in the minute, and is not held off. The answers: `payment_pending` with an hour for a period that has not started yet or a store that reports the payment pending; `receipt_invalid` for a Play subscription that has ended, since Play issues a new token when it is bought again; `payment_pending` with a day for everything a later charge on the same receipt could still change — a revoked charge, one in an offer, no charge yet, a period over while the store still retries, a charge whose months from its period start have already run out, a plan this service does not sell, and an App Store subscription that has ended, which a resubscription under the same original id would continue. A charge whose period does not end after it starts is answered `internal`, and a deployment that cannot read a store's subscriptions answers `provider_not_configured`.
|
||||
- Errors: `receipt_invalid`, `test_purchase`, `payment_pending`, `provider_unavailable` — see the classes below; `rate_limited` for a Play subscription when this path's Play checks for the minute are used; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store. `receipt_invalid`, `test_purchase` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger.
|
||||
- **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good, but for an App Store subscription charge, which stops holding the profile a day after its period ends.
|
||||
- **Terminal**, `receipt_invalid` — only a verdict no later attempt could change: the store answered about this purchase and cannot change its mind, or the evidence can never verify. Anything that might answer differently later — the store's lag, an outage, this service's own misconfiguration or bug, a shape of evidence this service does not know yet — is one of the other classes.
|
||||
- **Terminal and named**, `test_purchase` — the store vouched for a purchase that cost nothing: Apple's `Sandbox`, or Play's `purchaseType` test. It is separated from `receipt_invalid` for one reason: the device that made the purchase already knows it was a test one, so naming it reveals nothing a forger could iterate on, and it lets the app say why instead of reporting a generic failure. Every other refusal stays lumped together for exactly that reason.
|
||||
- **Pending**, `payment_pending` — a real purchase the store has not settled.
|
||||
- **Unreachable**, `provider_unavailable` — the store was not asked or did not answer.
|
||||
- **Internal**, `internal` — this service's own failure, never the store's answer.
|
||||
- **App Store.** Apple is verified offline, so it has no "not yet".
|
||||
- `receipt_invalid`: a JWS that is not three base64url parts or names no `transactionId`; an `x5c` certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a `revocationDate`, refunded or revoked.
|
||||
- `test_purchase`: refused by the service after the verifier vouched for it, a `Sandbox` transaction.
|
||||
- `internal`: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's `bundleId`, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1.
|
||||
- **App Store.** Apple's receipt is verified offline, so it has no "not yet"; a subscription's status is then read from the App Store Server API, which can fail to answer.
|
||||
- `receipt_invalid`: a JWS that is not three base64url parts or names no `transactionId`, or, for an auto-renewable subscription, no `originalTransactionId`; an `x5c` certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a `revocationDate`, refunded or revoked; and a subscription status whose transaction is family-shared.
|
||||
- `test_purchase`: refused by the service after the verifier vouched for it, a `Sandbox` transaction, and a subscription status from the `Sandbox` environment.
|
||||
- `provider_unavailable`, for a subscription's status only: network failure or timeout, and every error response but 401 and 403 — a 404 included, which Apple answers for an original transaction id its environment does not hold — 429 and 5xx among them; and an answer that names no such subscription, as after a Sandbox purchase history is cleared, which is no more certain than that 404.
|
||||
- `internal`: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's `bundleId`, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1. For a subscription's status: Apple refusing this service's API token or key (401, 403); an original transaction id that is not all digits; an answer over a mebibyte, or whose list of subscriptions or entry for this subscription cannot be read, an unreadable entry for another subscription being ignored; a signed transaction or renewal info that fails any check above (Apple answered it and no client presented it, so it is never `receipt_invalid`), is not a subscription transaction or renewal info, names another app's bundle id or another subscription, or an unknown environment; and a status this service does not know.
|
||||
- `product_unavailable`: a product this service does not price — the verifier vouched for the transaction either way.
|
||||
- `provider_not_configured`: the deployment has no `[apple]` section.
|
||||
- `provider_not_configured`: the deployment has no `[apple]` section, and, for a subscription, an `[apple]` section without an App Store Server API key.
|
||||
- **Google Play.** Only a purchase record is a verdict.
|
||||
- `receipt_invalid`: `purchaseState` canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one.
|
||||
- `test_purchase`: refused by the service after the verifier vouched for it, `purchaseType` test, a license tester's purchase.
|
||||
- `payment_pending`: `purchaseState` pending.
|
||||
- `provider_unavailable`: network failure or timeout, and every error response from `purchases.products.get` but 401 and 403 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400 or 410 calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away.
|
||||
- `internal`: Google refusing this service's credentials or permissions — a 401 or 403 from `purchases.products.get`, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a `purchaseState` this service does not know; and a `purchaseType` other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase.
|
||||
- `receipt_invalid`: `purchaseState` canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one; and a 410 from `purchases.subscriptionsv2.get`, a subscription that expired too long ago to read, on a first purchase (a renewal check answers it as ended).
|
||||
- `test_purchase`: refused by the service after the verifier vouched for it, `purchaseType` test, a license tester's purchase, and a subscription with `testPurchase`.
|
||||
- `payment_pending`: `purchaseState` pending, and `SUBSCRIPTION_STATE_PENDING`.
|
||||
- `provider_unavailable`: network failure or timeout, and every error response from `purchases.products.get` or `purchases.subscriptionsv2.get` except 401, 403 and a subscription check's 410 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400, or a 410 from `purchases.products.get`, calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away.
|
||||
- `internal`: Google refusing this service's credentials or permissions — a 401 or 403 from `purchases.products.get` or `purchases.subscriptionsv2.get`, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a `purchaseState` or `subscriptionState` this service does not know; a subscription that is not pending with more than one line item, or with none unless it has ended (`SUBSCRIPTION_STATE_EXPIRED` or `SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED`); a charge without its `expiryTime`, product or base plan, and a charge for a base plan this service sells under another product; and a `purchaseType` other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase.
|
||||
- `product_unavailable` and `provider_not_configured` as for the App Store, the latter without a `[google]` section.
|
||||
- Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — `provider_unavailable` when Play does not answer, `internal` when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers `internal` to every Play purchase the app has not already acknowledged itself.
|
||||
- Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed; a subscription is acknowledged with `purchases.subscriptions.acknowledge` before its first charge is credited. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — `provider_unavailable` when Play does not answer, `internal` when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers `internal` to every Play purchase the app has not already acknowledged itself.
|
||||
- Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them.
|
||||
- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. Its `badgeRequest` is to be dropped (see above).
|
||||
- `upgradeBadgeSubscription` → `badgeCredential` — an App Store subscription raised to a higher tier, on the same key. The client sends it when the device sees a transaction of the purchase's own subscription for a higher tier, bought after the purchase's last charge credit in its ledger and not expired (see Upgrades); it carries `payment`, that transaction, and `balance`, and makes the service ask the store now rather than at the next renewal check. It is refused before the store is asked when the transaction does not verify (`receipt_invalid`), when it is of no subscription product, of another subscription, of a purchase no App Store subscription funds, or from Play (`bad_request`), and for an unknown key (`unknown_purchase_key`). The store is asked at most once an hour per subscription, an hour shared with the renewal checks. A transaction of the tier the ledger already holds is answered from the record. Otherwise the store is asked and its latest charge is granted as a renewal check grants it, of whatever tier, and the answer is the new tier's credential once that tier is granted; until then the answer is `payment_pending` with a `retryAfter` to the next allowed check, never the old tier's credential. The client retries it a bounded number of times for each hand-over of the transaction, then sets it aside until the device hands it over again. The tier granted is the store's; the transaction only names the subscription and the tier to wait for, so a lower tier sent here is granted only once the store charges it, at its renewal.
|
||||
- `transferBadge` → `badgeCredential` — Restore on another device or profile: it carries `masterKey` and `payment`, the store receipt of a subscription credited to another key, and moves the subscription to the signing key, which must hold no purchase yet (`bad_request`). The store is asked first, at most once an hour per subscription as for `purchaseBadge` but in an hour of its own, so a Restore right after the purchase is not held off by the purchase's check. An App Store transaction other than the subscription's latest is answered `payment_pending`, so an older receipt stops moving it once the store charges again. The store's current charge must be one a first purchase could be credited, and is answered as for a first purchase when it is not. The old purchase is lapsed and debited `transferOut {toPurchaseKey}` of its remaining months, even none, and is answered `subscription = ended` with no `credential` from then on; the new purchase is credited that charge from its period start, as a first purchase is, and the month due is issued. A payment moves again only once the credential issued to the key it last moved from has expired (`transfer_limited`): a store receipt is not a one-time secret, so this keeps each paid month with at most two keys. A payment credited to no key is credited as by `purchaseBadge`. A one-time purchase is not moved (`receipt_used`): its months were issued at purchase.
|
||||
- `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13).
|
||||
- **Subscriptions.** The service asks the store whenever a month is due, at most once an hour per subscription whether or not the store answers, and answers requests in between from what it last read; a request with no month due is answered as above, without asking. While the paid coverage runs, as an annual plan's does, a store that reports the subscription ended, or the charge it was granted for revoked, ends it: the months not yet issued are debited as `refund`, at the time of the request after a lapse of those that ran out, and the answer is `subscription = ended` with no `credential` — also when the service already read it ended within the hour. Any other answer issues the month due as above, a store that fails or does not answer included, and a new charge it reports is recorded and granted only once the coverage ends. Once coverage has ended, a charge it can grant, by the rule `purchaseBadge` applies to a first charge but never twice, is granted at the store's period start, or at the ledger's last row when that is later, so rows are never dated before the row they follow, after a lapse of what ran out before it, and the month due is issued. A charge of another badge type is granted as that type: what remains of the old run is debited as `upgrade` naming the purchase's own key, the new run starts at that date, and the purchase's badge type follows it. Otherwise no ledger row is written: the charge, if any, and the state are recorded, and the answer carries no new `credential` and the `subscription` the store reported. A store that does not answer is answered `provider_unavailable` with a `retryAfter` of the hour. Every `badgeCredential` answer for a subscription purchase carries `subscription`. A development deployment may ask its store at every request, during coverage too, and bank the months of a charge it can grant, issued once coverage reaches them, and ends it as above; a real store is asked at every request only by a development deployment that also credits test purchases.
|
||||
- `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13).
|
||||
|
||||
## Upgrades
|
||||
|
||||
Always a new purchase under a new key, except store subscriptions, where the store owns the change.
|
||||
Always a new purchase under a new key, except App Store subscriptions, where the store owns the change and the purchase keeps its key.
|
||||
|
||||
- Non-store: `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: 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.
|
||||
- Store subscription, app-led: the native subscription-group flow (Apple — immediate, with the store's prorated refund; Google — per replacement mode), then `upgradeBadgeSubscription` with the new evidence.
|
||||
- Store subscription, sheet-led, and every downgrade: the client sends nothing — the service discovers the change from provider state and notifications, and each renewal credits months of the charged badge type.
|
||||
- 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 bought after the purchase's last charge credit and not yet expired, however late the device sees it, and is granted as soon as the store reports the charge; each hand-over is retried a bounded number of times, and the next one holds it again. Every other change — every lower tier, which Apple charges from the next renewal, a change of plan length, and a higher tier whose transaction has expired or is no newer than the last charge credit — 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: the client never starts a plan change. Each badge is its own Play product, with a monthly and an annual base plan, and when tested (2026-10-22) Play's own pages offered neither another badge nor another billing period, so another badge is bought as a new purchase once the current one ends. A plan change made in Play's own pages, should one become possible, 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
|
||||
|
||||
@@ -75,9 +80,11 @@ Always a new purchase under a new key, except store subscriptions, where the sto
|
||||
|
||||
## Statement and balance
|
||||
|
||||
The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. Month boundaries are counted from `balanceAnchorTs`, the start of the current run of months, and not from `balanceStartTs` — counting from the moving start would compound the day-of-month clipping of a short month, so a run beginning 31 January would reach 28 February and never return to the 31st. The service sets a new anchor only where a lapsed run restarts; months granted while coverage still runs extend it on its existing anchor.
|
||||
The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. Month boundaries are counted from `balanceAnchorTs`, the start of the current run of months, and not from `balanceStartTs` — counting from the moving start would compound the day-of-month clipping of a short month, so a run beginning 31 January would reach 28 February and never return to the 31st. The service sets a new anchor only where a run restarts: one that has lapsed, or a grant of another badge type, which starts a run of its own; months of the same type granted while coverage still runs extend it on its existing anchor.
|
||||
|
||||
`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. An unknown type is stored as received and decoded after an app upgrade.
|
||||
`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, or to the previous entry's `createdAt` when that is later, so dates never decrease; 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, 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.
|
||||
|
||||
@@ -85,4 +92,4 @@ An assertion that names an entry the service holds is a prefix: the service proc
|
||||
|
||||
## Errors
|
||||
|
||||
`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; `test_purchase` is named apart from them because the device that bought it already knows; `receipt_used` — credited to another key; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command.
|
||||
`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; `test_purchase` is named apart from them because the device that bought it already knows; `receipt_used` — credited to another key; `transfer_limited` — the subscription moved from a key whose credential is still valid, with no `retryAfter`; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command.
|
||||
@@ -22,12 +22,6 @@
|
||||
"badgeExpiry": {"type": "timestamp"}
|
||||
}
|
||||
},
|
||||
"badgeRequest": {
|
||||
"properties": {
|
||||
"masterKey": {"ref": "base64url"},
|
||||
"badgeInfo": {"ref": "badgeInfo"}
|
||||
}
|
||||
},
|
||||
"credential": {
|
||||
"properties": {
|
||||
"badgeKeyIdx": {"type": "uint16"},
|
||||
@@ -230,7 +224,8 @@
|
||||
"metadata": {"comment": "a redeemed code; its invoice belongs to the buyer, not the redeemer"}
|
||||
},
|
||||
"charge": {
|
||||
"properties": {"chargeId": {"type": "string"}}
|
||||
"properties": {"chargeId": {"type": "string"}},
|
||||
"metadata": {"comment": "a store subscription's charge, dated (createdAt) to the store's billing period start, or to the previous entry's createdAt when that is later"}
|
||||
},
|
||||
"support": {
|
||||
"optionalProperties": {}
|
||||
@@ -321,17 +316,22 @@
|
||||
},
|
||||
"upgradeBadgeSubscription": {
|
||||
"properties": {
|
||||
"badgeRequest": {
|
||||
"ref": "badgeRequest",
|
||||
"metadata": {"comment": "known wrong: to be dropped when implemented, see badges-rpc.md"}
|
||||
},
|
||||
"payment": {
|
||||
"ref": "payment",
|
||||
"metadata": {"comment": "store payments only"}
|
||||
"metadata": {"comment": "apple only: a transaction of the purchase's own subscription"}
|
||||
},
|
||||
"balance": {"ref": "balance"}
|
||||
}
|
||||
},
|
||||
"transferBadge": {
|
||||
"properties": {
|
||||
"masterKey": {"ref": "base64url"},
|
||||
"payment": {
|
||||
"ref": "payment",
|
||||
"metadata": {"comment": "a store subscription's receipt, credited to another key"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issueBadge": {
|
||||
"properties": {
|
||||
"balance": {"ref": "balance"}
|
||||
@@ -375,6 +375,10 @@
|
||||
"receipt": {
|
||||
"type": "string",
|
||||
"metadata": {"comment": "not provided for lifetime badges"}
|
||||
},
|
||||
"subscription": {
|
||||
"type": "string",
|
||||
"metadata": {"comment": "SubscriptionState: renewing, cancelled, billingRetry, ended; any other value is read as not ended. Present exactly when a store subscription funds the purchase"}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user