From 3e2d40447a809e6689fc304ea4b67821cf0e572e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:30:59 +0400 Subject: [PATCH] docs: mark upgradeBadgeSubscription's badgeRequest as to be dropped; trim store purchase comments --- apps/simplex-badge-service/src/BadgeService/Catalog.hs | 6 ++---- apps/simplex-badge-service/src/BadgeService/Service.hs | 4 ---- apps/simplex-badge-service/src/BadgeService/Store.hs | 7 ++----- docs/protocol/badges-rpc.md | 8 ++++---- docs/protocol/badges-rpc.schema.json | 5 ++++- src/Simplex/Chat/Badges/Service.hs | 2 +- src/Simplex/Chat/Store/Badges.hs | 1 - tests/Bots/BadgeService/FakeStore.hs | 5 ++--- 8 files changed, 15 insertions(+), 23 deletions(-) diff --git a/apps/simplex-badge-service/src/BadgeService/Catalog.hs b/apps/simplex-badge-service/src/BadgeService/Catalog.hs index 00cbf2629e..866ea5bc96 100644 --- a/apps/simplex-badge-service/src/BadgeService/Catalog.hs +++ b/apps/simplex-badge-service/src/BadgeService/Catalog.hs @@ -136,16 +136,14 @@ defaultCatalog seededAt = (prices, offers) createdAt = seededAt } --- | What one unit of a store product grants. data StoreProduct = StoreProduct { badgeType :: BadgeType, months :: Int } deriving (Eq, Show) --- | A receipt proves only which product was paid for, so the badge comes from here and never from --- the client. Mirrors the one-time products of BadgeStore.swift and BadgeStore.kt, whose ids differ --- by store; their subscriptions are refused, since nothing adds a renewal's months to a badge yet. +-- | A receipt proves only the product paid for, so the badge comes from here, never from the client. +-- Mirrors the one-time SKUs of BadgeStore.swift and .kt; subscriptions wait until renewals add months. storeProduct :: PaymentProvider -> Text -> Maybe StoreProduct storeProduct provider productId = case (provider, productId) of (PPApple, "BADGE_SUPPORTER_01") -> Just $ StoreProduct BTSupporter 1 diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 32f68a3818..8d483ca0e8 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -464,8 +464,6 @@ storeRefusalResponse = \case SRPending -> pure $ errorResponse BSEPaymentPending SRUnreachable reason -> logWarn ("store unreachable: " <> reason) $> errorResponse BSEProviderUnavailable --- | A funding already spent answers as the purchase it funded: that purchase again for its own key, --- and usedCode for any other. claimedResponse :: DB.Connection -> BadgeServiceErrorCode -> C.PublicKeyEd25519 -> FundingClaim -> IO (Either BadgeServiceResponse ()) claimedResponse db usedCode purchaseKey = \case Unclaimed -> pure $ Right () @@ -476,7 +474,6 @@ claimedResponse db usedCode purchaseKey = \case maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) <$> getLedgerEntries db badgePurchaseId 0 --- | The grant of a new purchase and the first month issued from it, signed; nothing is written. signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either BadgeServiceResponse (StatementEntry, (StatementEntry, BadgeCredential))) signFirstMonth key cc masterKey badgeType months credit now = do (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc @@ -491,7 +488,6 @@ signFirstMonth key cc masterKey badgeType months credit now = do Left e -> logError ("badge service signing failed: " <> T.pack e) $> Left (errorResponse BSEInternal) Right signed -> pure $ Right (granted, signed) --- | Writes what signFirstMonth signed onto the purchase just claimed for it. firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> (StatementEntry, (StatementEntry, BadgeCredential)) -> IO BadgeServiceResponse firstMonthResponse db purchaseId creditPaymentId_ (granted, signed) = do appendLedgerPlan db purchaseId creditPaymentId_ [granted] $ Just $ issuanceAfter granted signed diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index 59e423c3d7..946b363cba 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -66,7 +66,6 @@ data IssuedCode = IssuedCode redemption :: FundingClaim } --- | Whether a code or a store payment has already funded a purchase, and which. data FundingClaim = Unclaimed | Claimed ClaimedPurchase @@ -121,7 +120,6 @@ getBadgeCode db codeHash = toCode (badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, purchaseId_, purchaseKey_, credential_) = IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redemption = fundingClaim purchaseId_ purchaseKey_ credential_} --- | The purchase a store transaction's payment funds, read the same way as a code's. getStorePaymentClaim :: DB.Connection -> PaymentProvider -> Text -> IO FundingClaim getStorePaymentClaim db provider providerRef = maybeFirstRow' Unclaimed (\(purchaseId, purchaseKey, credential_) -> fundingClaim (Just purchaseId) (Just purchaseKey) credential_) $ @@ -287,9 +285,8 @@ createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = Bad (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) Just <$> insertedRowId db --- | Must run in the same transaction as the credential rows. The payment is inserted before the --- purchase, and its (provider, provider_ref) is unique: a transaction presented twice at once, or --- under another key, inserts nothing the second time and fails. +-- | In the transaction of the credential rows. The payment's unique (provider, provider_ref) is the +-- claim: a transaction presented twice at once, or under another key, inserts nothing the second time. createStorePurchase :: DB.Connection -> NewStorePurchase -> UTCTime -> IO (Maybe Int64) createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do claimed <- diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index e9a8f4cd78..dc2aeb15d4 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -20,21 +20,21 @@ A timeout hides the outcome, so the client repeats the identical signed request - `getBadgeInvoice` — returns the open invoice again; a new invoice is created only when none is open. - `redeemBadgeCode` — a code already redeemed by the signing key returns the same `badgeCredential` and writes nothing; redeemed by another key, `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. +- `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. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. - `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. ## Commands -`upgradeBadgeSubscription` carries `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; `badgeExtra` is reserved and must be empty. `issueBadge` carries no `badgeRequest`: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state. +No command lets the client state what is signed. The tier is that of whatever funded the purchase, the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), which every credential of that week shares, and `badgeExtra` is reserved and always empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and mark its credential out of the week's anonymity set. `issueBadge` carries no `badgeRequest`: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state. `upgradeBadgeSubscription` still carries `badgeRequest` in the schema, and that shape is known to be wrong: the field must be dropped when the command is implemented, since it works on a purchase whose master key the service already holds and, like `issueBadge`, has nothing to state. - `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 another key redeemed it, `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 tier and months are those of the product the funding proves, and the expiry is the one every credential for that week shares, so the client has nothing to state. Errors: `receipt_invalid` for a receipt the store does not vouch for, whether forged, malformed, unknown or refunded; `receipt_used` when another key was credited with it; `product_unavailable` for a product that grants no badge; `payment_pending` while the store has not settled it, recording nothing. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `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 tier and months are those of the product the funding proves, and the expiry is the one every credential for that week shares, so the client has nothing to state. Errors: `receipt_invalid` for a receipt the store does not vouch for, whether forged, malformed, unknown or refunded; `receipt_used` when another key was credited with it; `product_unavailable` for a product that grants no badge; `payment_pending` while the store has not settled it and `provider_unavailable` while the store cannot be asked, both recording nothing. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. - 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. +- `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). - `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). - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 52cdcd0948..f9ca9d4a14 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -321,7 +321,10 @@ }, "upgradeBadgeSubscription": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "badgeRequest": { + "ref": "badgeRequest", + "metadata": {"comment": "known wrong: to be dropped when implemented, see badges-rpc.md"} + }, "payment": { "ref": "payment", "metadata": {"comment": "store payments only"} diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index 8a26a09af4..3bfa84b9f1 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -92,7 +92,7 @@ data BadgeServiceCommand upgrade :: Maybe BadgeUpgrade -- upgrade store 1-time badge } | BSCUpgradeBadgeSubscription - { badgeRequest :: BadgeRequest, + { badgeRequest :: BadgeRequest, -- TODO [badges] drop when implemented: a client-stated expiry leaves the week's anonymity set payment :: ServicePayment, -- store payments only balance :: BadgeBalance } diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index e967f6fdc2..670bc0aaff 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -72,7 +72,6 @@ data BadgeStash = BadgeStash masterKey :: BadgeMasterKey } --- | The row a stash is kept in, which a purchase bought with it references. data BadgeStashRef = BSRCodeRedemption Int64 | BSRStoreReceipt Int64 -- | A store and its own id for one transaction. The evidence is not the key: the store may sign it diff --git a/tests/Bots/BadgeService/FakeStore.hs b/tests/Bots/BadgeService/FakeStore.hs index 16df9d64c8..e98d1a3503 100644 --- a/tests/Bots/BadgeService/FakeStore.hs +++ b/tests/Bots/BadgeService/FakeStore.hs @@ -33,9 +33,8 @@ import System.FilePath (()) fixtureDir :: FilePath fixtureDir = "apps" "simplex-badge-service" "test-fixtures" "apple" --- | Stores that vouch for a fixed set of receipts. The Apple JWS are the fixtures wrapped in a JWS --- envelope with no real signature, and are accepted as these exact strings only, never because a --- payload decodes - which is what a real verifier must refuse to do. +-- | The Apple JWS are the fixtures under no real signature, so they are accepted as these exact +-- strings only, never because a payload decodes - which a real verifier must refuse to do. data FakeStore = FakeStore { appleSupporterJWS :: Text, appleLegendJWS :: Text,