diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 19f50de743..16a944d7bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1037,15 +1037,13 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } else { NavigationBarBackground(oneHandUI.value, true) } - // TEMP-DISABLED-FOR-BADGES-QA: auto-hide of ToggleChatListCard at 3+ chats blocks visual QA of - // the SupportSimpleXBanner alongside it. Restore before merging. - // if (!oneHandUICardShown.value) { - // LaunchedEffect(chats.size) { - // if (chats.size >= 3) { - // appPrefs.oneHandUICardShown.set(true) - // } - // } - // } + if (!oneHandUICardShown.value) { + LaunchedEffect(chats.size) { + if (chats.size >= 3) { + appPrefs.oneHandUICardShown.set(true) + } + } + } LaunchedEffect(activeFilter.value) { searchText.value = TextFieldValue("") diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs index 04b7674cec..daa6905033 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -31,7 +31,43 @@ m20260806_badge_service_schema = servicePrefix [r| ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; + +CREATE TABLE @badge_codes( + badge_code_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + code_hash BYTEA NOT NULL, + badge_type TEXT NOT NULL, + months SMALLINT NOT NULL, + code_payment_status TEXT NOT NULL, + redeemed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(code_hash) +); + +ALTER TABLE @badge_purchases ADD COLUMN badge_code_id BIGINT REFERENCES @badge_codes; + +CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id); + +CREATE TABLE @badge_code_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); |] down_m20260806_badge_service_schema :: Text -down_m20260806_badge_service_schema = badgeSchemaDown servicePrefix +down_m20260806_badge_service_schema = + withPrefix + servicePrefix + [r| +DROP TABLE @badge_code_invoices; + +DROP INDEX @idx_badge_purchases_code; +|] + <> badgeSchemaDown servicePrefix + <> withPrefix + servicePrefix + [r| +DROP TABLE @badge_codes; +|] diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index b0974f177d..d96c779094 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -32,7 +32,43 @@ m20260806_badge_service_schema = servicePrefix [sql| ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; + +CREATE TABLE @badge_codes( + badge_code_id INTEGER PRIMARY KEY AUTOINCREMENT, + code_hash BLOB NOT NULL, + badge_type TEXT NOT NULL, + months INTEGER NOT NULL, + code_payment_status TEXT NOT NULL, + redeemed_at TEXT, + created_at TEXT NOT NULL, + UNIQUE(code_hash) +); + +ALTER TABLE @badge_purchases ADD COLUMN badge_code_id INTEGER REFERENCES @badge_codes; + +CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id); + +CREATE TABLE @badge_code_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months INTEGER NOT NULL, + created_at TEXT NOT NULL +); |] down_m20260806_badge_service_schema :: Query -down_m20260806_badge_service_schema = badgeSchemaDown servicePrefix +down_m20260806_badge_service_schema = + withPrefix + servicePrefix + [sql| +DROP TABLE @badge_code_invoices; + +DROP INDEX @idx_badge_purchases_code; +|] + <> badgeSchemaDown servicePrefix + <> withPrefix + servicePrefix + [sql| +DROP TABLE @badge_codes; +|] diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 7ce3b78b34..f3bafec240 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -10,15 +10,16 @@ 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`, and a key it holds no record of with `unknown_purchase_key`. +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`. -A purchase record is created by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, `code`, or `receipt`. +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. ## Idempotency A timeout hides the outcome, so the client repeats the identical signed request at its next trigger, never on a poll timer. - `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. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. @@ -30,7 +31,8 @@ A timeout hides the outcome, so the client repeats the identical signed request - `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`. -- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `code`; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `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` 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. 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. - `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. 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). @@ -61,4 +63,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 and revoked codes; `code_used` — redeemed under another key. `receipt_invalid` covers unknown receipts. 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` — redeemed under another key; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 00ecc69874..6a3b2f81d4 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -102,9 +102,6 @@ "invoice": { "properties": {"invoiceId": {"type": "string"}} }, - "code": { - "properties": {"code": {"type": "string"}} - }, "receipt": { "properties": { "receipt": { @@ -298,6 +295,12 @@ } } }, + "redeemBadgeCode": { + "properties": { + "masterKey": {"ref": "base64url"}, + "code": {"type": "string"} + } + }, "purchaseBadge": { "properties": { "badgeRequest": {"ref": "badgeRequest"}, diff --git a/plans/2026-08-27-badges-mvp-streams.md b/plans/2026-08-27-badges-mvp-streams.md new file mode 100644 index 0000000000..444ad73f91 --- /dev/null +++ b/plans/2026-08-27-badges-mvp-streams.md @@ -0,0 +1,194 @@ +# Supporter badges — beta slice and stream plan + +**2026-08-27** · protocol `docs/protocol/badges-rpc.{md,schema.json}` · model and copy rules `plans/2026-07-30-supporter-badges-v3-ux.md` · core API `plans/2026-07-31-badges-core-implementation.md`. The product plan's §4 wire protocol was superseded before implementation; `badges-rpc.md` is the protocol. + +--- + +## 1. The slice + +Beta ships one route to a badge: **buy a code in a browser, redeem it in the app.** The app asks no purchase questions — tier, duration and method are the site's. + +Deferred, still in the protocol: in-app invoices, store purchase, subscriptions, upgrades, transfers, pause, alerts. + +**First, no dependencies:** divert the apps' purchase path so nothing reaches a store charge — it takes real money today and issues no badge. The views and the store code stay in place, uncalled. + +Accepted: a code is a bearer instrument. Whoever sees it can spend it, and this slice has no revocation. + +## 2. State on `badges` + +Other branches may carry work this does not see. + +- **Written** — credential signing, verification, proofs, status, presentation; protocol types, RPC docs, JTD schema; client and service schema incl. this plan's tables; app banner, settings entry, what's-new +- **Scaffold** — badge service answers every request `unsupported_version` +- **Not written** — JSON instances for most protocol types; core client badge API, store, worker; checkout site and providers; app redeem view (title-only stub), badge-state screen, browser hand-off +- **To divert** — the app's tier/duration screens, which today lead to a store purchase + +Registering the client migration needs `STRICT` on all ten SQLite tables; Postgres must not have it. + +--- + +## 3. Contracts between streams + +**The code** — `SXB-` + 20 Crockford chars in four groups, one of them a check character. Normalisation upper-cases and folds `I`/`L`→`1`, `O`→`0`, identically both sides. Only `SHA-256` of the normalised code is stored anywhere. Site codes are `HMAC(secret, orderId)`; operator codes are random. + +**`redeemBadgeCode`** over service RPC — signed with an Ed25519 key generated per code and reused by a retry of it. Carries the badge master key and the code, nothing else. Returns the signed credential and a **statement**, or `code_invalid` | `code_used` | `code_expired` | `rate_limited` | `internal`. + +A *statement* is an extract of the badge's ledger — the months balance, as a list of entries. The ledger is authored by the service alone; the client keeps a verbatim replica and reads its balance from the last entry. + +**The catalog** — one source in the service, one total function. The site renders those totals; the browser never multiplies a price by a month count. The app does not read it this release. + +The code is the only thing crossing site → app. The site never sees a purchase key, credential or ledger; the app never sees an order. + +--- + +## 4. Stream 1 — codes for badges + +### 4.1 Redemption becomes its own command + +`purchaseBadge {badgeRequest, payment:{type:"code"}}` asks the caller for a tier and expiry it cannot know and the service must override. + +- `redeemBadgeCode {masterKey, code}` → `badgeCredential {credential, receipt?, statement}` +- `code` leaves the payment union; `purchaseBadge` keeps the rest — `apple`, `google`, `invoice`, `receipt` — and stays unimplemented +- the tier is stated by the credential, which the client verifies +- client command `APIRedeemBadgeCode {userId, code}` + +One thing here fails quietly. The request envelope carries an optional `purchaseKey`, and the service decides per command whether that key must already name a purchase — most require it and answer `unknown_purchase_key` otherwise. `redeemBadgeCode` is the exception: it *creates* the purchase, so on a first redemption the key is always unknown. Miss the special case and the lookup falls through to the default, every first redemption is refused, and nothing fails to compile because it is a runtime lookup. Worth its own test. + +### 4.2 Schema + +The tables, in layers: + +- **money** — `invoices` (an amount owed), `payments` (an amount paid), `subscription_charges`. Generic: they carry no idea what was bought. +- **catalog** — `badge_prices` (tier → price per month), `badge_offers` (duration discounts). +- **what an invoice bought** — `badge_invoices` for a purchase, `badge_code_invoices` for a voucher. Same shape, except the second names no purchase: at the time of sale none exists, and the buyer may never be the redeemer. +- **the badge** — `badge_purchases` is the anchor: keys, tier, status, and what funded it — `payment_id` for an in-app purchase, or a code. The code column is added separately on each side, each pointing at its own code table. Its balance is `badge_ledger`, its credentials `badge_issuances`, and `users.shown_badge_id` names the one on show. +- **the voucher** — `badge_codes`, service only: the hash, the tier and months it is worth, and `code_payment_status` — paid, unpaid or free — so a minted code is told from a sold one without joining invoices. Never a badge itself. +- **the attempt** — `badge_code_redemptions`, client only. It holds the code in plaintext, because the client sends the code and not its hash, and the keys the redemption is signed with, so a retry can be the same signer (§4.3). + +The beta path: the site writes an invoice and what it bought, then a code. The app redeems that code, which creates the purchase and its first issuance — and, from milestone D, its ledger. + +New here are `badge_code_redemptions` (client) and `badge_codes`, `badge_code_invoices` (service); all are in the migration modules already. Every schema change touches four files — the shared block, the client-only section and the service migrations each exist twice, SQLite and Postgres, unlinked by the build. + +### 4.3 Retry is safe + +A redemption is signed with a key the client generates for it. If that key is generated fresh on every attempt, a timeout is unrecoverable: the service may already have redeemed the code, and the retry arrives as a *different* signer, so it reads as someone else presenting a spent code — `code_used`. The user has paid and cannot get the badge. + +The fix is for a retry to be the **same** signer: + +- the service's replay keys on **(code hash, verified signer)**: the same code from the same key returns the credential it already issued and writes nothing +- so the client writes the signing keys into a `badge_code_redemptions` row **before** sending, found by the code, and a retry reads them back + +That row is a stash for the in-flight attempt. Its fate depends on the outcome: + +| outcome | the row | +|---|---| +| success | kept, and pointed at by the new purchase | +| terminal error — `code_invalid`, `code_used`, `code_expired` | deleted: the code will never work, so the keys are dead | +| timeout | **kept** — this is the case it exists for | + +The keys need a row of their own because **code redemption cannot create its `badge_purchases` row up front.** That row's badge-type columns are `NOT NULL` and a code carries no tier, so there is nowhere to put them until the service answers. An in-app purchase has no such problem — the tier was picked on screen, so it creates its purchase in `acquiring` immediately. + +### 4.4 Service + +Store layer, ledger transitions, credential signing, code minting and classification, RPC dispatcher, then the redeem and issue handlers. + +**Nothing is written until the credential is signed.** Look the code up, compute the ledger changes in memory, sign — and only then open one transaction that writes the purchase, the ledger rows, the issuance and the redemption together. Signing is the step most likely to fail for reasons unrelated to the request. If the code were marked redeemed first, a signing failure would leave it spent with no credential behind it: dead, and revivable only by an operator. Signing first means a failure touches nothing and the user can simply try again. + +**Errors say as little as possible.** An unknown code and a malformed one both answer `code_invalid`, so someone guessing learns nothing from the difference. A code already redeemed answers `code_used` — but only to a *different* key; the key that redeemed it gets its credential back (§4.3). + +### 4.5 Core client + +Badge store, commands and events, re-issue worker, redeem path. + +- verify the credential against the configured issuer keys **before** writing anything +- copy the statement's entries into the ledger replica exactly as received — never compute a balance locally, never edit or invent an entry; one author means client and service cannot disagree +- on success, one transaction: write the purchase and its issuance, copy the ledger entries, complete the code row, retire the profile's previous badge of the same kind, and point the profile at the new one — then release the lock and present to contacts +- split `addUserBadge` first — it verifies, stores and broadcasts under the global chat lock in one function and raises command errors; the redeem path needs a per-user lock and a service error code + +A profile shows one badge at a time and holds at most two: a paid one and an investor one. Redeeming a code fills that kind's place, and the purchase that was there moves to `superseded`. Its unspent months stay with it — purchases are unlinkable, so nothing can move a balance between them. That matters for **Add more months** (§4.6): a second code starts a new balance rather than topping up the old one. + +### 4.6 Apps + +- *Support SimpleX* — **Get the code** (opens the site; absent on store builds) and **Redeem the code** (everywhere) +- *Redeem code* — formats as typed, folds ambiguous characters, verifies the check character before sending, one message per service error +- *Supporter perks* — the badge, that it is shown, the date support **ends**, **Add more months**; plus the ended state +- **Diverted** — tier and duration screens leave the flow, kept compiled and uncalled; store product loading and the catalog command lose their last callers +- **Copy** — one date, the paid-through date from the ledger, never the credential's expiry; *ends*, never *renews* + +### 4.7 Issuing without selling + +Compensation codes need no new mechanism: minted by the operator, random rather than derived, printed once, stored as hashes. + +Investor badges are not a special case: an operator mints a code of that tier and it is redeemed by the same path as any other. `code_payment_status` records that it was minted rather than sold; the redemption path never reads it. + +A badge that never expires is a separate question, and deferred — the ledger holds a month count, so "forever" has no representation. Until it does, a long finite term serves: the count is a byte, so twenty years is expressible. + +### 4.8 Order of work + +**The ledger is stubbed until last** — redemption issues one credential and writes no ledger rows, and the statement comes back empty. Everything before D is a working badge without accounting. + +**First, independent:** divert the apps' purchase path (§1). + +**A — core, redeemable from the CLI.** Migration registered with `STRICT` and both dumps regenerated; code format in the shared library; `redeemBadgeCode` types, JSON, schema and docs; service store, signing, dispatcher and redeem handler; a minimal mint command. + +`APIRedeemBadgeCode` lands here **with its `chatCommandP` parser and `View.hs` rendering**, so a code can be redeemed from the terminal with no app involved. That is what proves the round trip — RPC delivery, signing, credential verification, the profile update — before any UI exists to confuse the picture, and it stays the fastest way to reproduce a redemption afterwards. + +Tests land here too, not later: `tests/Bots/BadgeServiceTests.hs` already starts the service in-process and hands a chat client its address, so a mint-then-redeem case is an extension of the existing harness rather than new scaffolding. Cover the code redeeming into a badge, and an unknown code answering `code_invalid`. That spec sits under `xdescribe''`, which skips when `CI` is set — so it must be run locally; CI green is not evidence for it. + +*Done when* a code minted by that command and redeemed from the terminal puts a badge on the profile and contacts see it, and those tests pass locally. + +**B — apps.** Support screen with its two actions, redeem screen, badge-state screen, copy. Both platforms in step. *Done when* the same works by pasting a code into the app. + +**C — unhappy paths.** Retry idempotency — keys stashed before sending, replay keyed on (code hash, signer). Error mapping for `code_invalid`, `code_used`, `code_expired` and a locally failed verification. + +**D — ledger.** Transitions, credit on redemption, debit on issue, lapse; statement in the response; client replica; monthly re-issue worker. *Done when* a three-month code re-issues at the month boundary and both ledgers match row for row. Property tests here: balance never below zero, issuance debits exactly one month, lapse removes only elapsed unissued months. + +### 4.9 Done means + +- an operator-minted code redeems on desktop, Android and iOS and shows on the profile and in member lists +- the same code twice from one profile returns the same badge and consumes nothing +- a timeout then a retry issues exactly one badge, no operator involved +- every service error renders inline, as does a locally failed credential verification +- an operator-minted code redeems by the same path as one bought on the site, whatever its tier +- client and service ledger rows match row for row; a crossed month boundary re-issues without a restart +- redeeming a code writes no payment row + +--- + +## 5. Stream 2 — selling codes on the web + +Takes money and produces codes: a page, card and crypto payment, and whatever server side that needs. Its only output that stream 1 consumes is a code. + +**What it owes stream 1** + +- codes in §3's format and normalisation, with only their `SHA-256` stored +- prices from the service catalog — one source and one total function, so the site and the app cannot disagree +- `badge_code_invoices` filled with what each invoice bought; the table is already in the schema + +**Its own to settle.** Hosting and deployment, endpoints, which providers, how the browser learns an order settled, how long a code stays retrievable after purchase, and what reference support resolves against. None of it reaches stream 1. + +**Before the tier page ships:** it advertises a storage-duration perk — confirm that perk exists (§6) or drop the claim. + +--- + +## 6. Stream 3 — perks + +What an active badge changes for the user: XFTP file size and storage duration, granted against the badge proof the sender presents. + +Integration points, in dependency order: + +1. a presentation context binding a proof to the operation it authorises, so a proof lifted from a profile cannot authorise an upload *(client)* +2. recipient-side size verification against the presented proof *(client)* +3. carrying the proof to the file server on upload *(simplexmq)* +4. issuer keys in file-server config, so it can verify one *(simplexmq)* +5. per-file size and retention derived from the verified badge type *(simplexmq)* + +1 and 2 are client-side and independent of the rest. Target values: supporter 2GB and 7 days, legend 5GB and 21 days. + +--- + +## 7. Ledger — for information + +The months accounting, and a mechanism inside stream 1 rather than a stream of its own: the service authors every entry, the client keeps a verbatim replica and reads the balance from the last one. + +Beta needs three operations — credit on redemption, debit on issue, lapse on elapsed months — which is milestone D. The rest of the ledger's operations arrive with the features that need them. diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index 6f15d0fa16..ff06fb7d94 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -62,6 +62,10 @@ data BadgeServiceCommand paymentVia :: ServicePaymentMethod, upgrade :: Maybe BadgeUpgrade -- upgrade non-store badge } + | BSCRedeemBadgeCode + { masterKey :: BadgeMasterKey, + code :: Text -- no badgeRequest: a code carries no tier for the client to state + } | BSCPurchaseBadge { badgeRequest :: BadgeRequest, payment :: ServicePayment, diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 70dd4dfa8c..c5289504f0 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Badges.Types BadgeItemStatus (..), OfferDiscount (..), BadgePurchaseStatus (..), + BadgeCodePaymentStatus (..), LedgerEntryType (..), LedgerCreditType (..), LedgerDebitType (..), @@ -22,7 +23,6 @@ module Simplex.Chat.Badges.Types ) where import qualified Data.Aeson as J -import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) @@ -58,6 +58,10 @@ data OfferDiscount data BadgePurchaseStatus = PSAcquiring | PSIssued | PSSuperseded | PSFailed deriving (Eq, Show) +-- unconfirmed draft +data BadgeCodePaymentStatus = CPSPaid | CPSUnpaid | CPSFree + deriving (Eq, Show) + -- confirmed data LedgerEntryType = LECredit {credit :: LedgerCreditType} | LEDebit {debit :: LedgerDebitType} deriving (Eq, Show) diff --git a/src/Simplex/Chat/PaymentService.hs b/src/Simplex/Chat/PaymentService.hs index a4484c469c..4cbfc0ef11 100644 --- a/src/Simplex/Chat/PaymentService.hs +++ b/src/Simplex/Chat/PaymentService.hs @@ -26,6 +26,5 @@ data ServicePayment = SPApple {jws :: Text} | SPGoogle {token :: Text} | SPInvoice {invoiceId :: InvoiceId} - | SPCode {code :: Text} | SPReceipt {receipt :: Text} -- transfer of unissued months deriving (Show) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs index 30908cdaf8..fd3024966d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs @@ -180,12 +180,23 @@ CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id badgeSchemaTablesDown :: Text badgeSchemaTablesDown = [r| +DROP INDEX @idx_badge_issuances_purchase; DROP TABLE @badge_issuances; +DROP INDEX @idx_badge_ledger_uuid; +DROP INDEX @idx_badge_ledger_purchase; +DROP INDEX @idx_badge_ledger_payment; +DROP INDEX @idx_badge_ledger_charge; +DROP INDEX @idx_badge_ledger_from_purchase; +DROP INDEX @idx_badge_ledger_to_purchase; DROP TABLE @badge_ledger; +DROP INDEX @idx_badge_subscription_changes_purchase; DROP TABLE @badge_subscription_changes; +DROP INDEX @idx_badge_invoices_purchase; DROP TABLE @badge_invoices; DROP TABLE @badge_purchases; DROP TABLE @subscription_charges; +DROP INDEX @idx_payments_provider_ref; +DROP INDEX @idx_payments_invoice; DROP TABLE @payments; DROP TABLE @invoices; DROP TABLE @badge_offers; @@ -217,11 +228,34 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); ALTER TABLE users ADD COLUMN shown_badge_id BIGINT REFERENCES badge_purchases ON DELETE SET NULL; + +CREATE TABLE badge_code_redemptions( + badge_code_redemption_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + code TEXT NOT NULL, + purchase_key BYTEA NOT NULL, + purchase_priv_key BYTEA NOT NULL, + master_key BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(code) +); + +CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_code_redemption_id BIGINT REFERENCES badge_code_redemptions; + +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(badge_code_redemption_id); |] down_m20261001_user_badges :: Text down_m20261001_user_badges = [r| +DROP INDEX idx_badge_purchases_code_redemption; +DROP INDEX idx_badge_purchases_user; ALTER TABLE users DROP COLUMN shown_badge_id; |] <> badgeSchemaDown "" + <> [r| +DROP INDEX idx_badge_code_redemptions_user; +DROP TABLE badge_code_redemptions; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs index f1015ae393..94b2fbc41e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs @@ -181,12 +181,23 @@ CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id badgeSchemaTablesDown :: Query badgeSchemaTablesDown = [sql| +DROP INDEX @idx_badge_issuances_purchase; DROP TABLE @badge_issuances; +DROP INDEX @idx_badge_ledger_uuid; +DROP INDEX @idx_badge_ledger_purchase; +DROP INDEX @idx_badge_ledger_payment; +DROP INDEX @idx_badge_ledger_charge; +DROP INDEX @idx_badge_ledger_from_purchase; +DROP INDEX @idx_badge_ledger_to_purchase; DROP TABLE @badge_ledger; +DROP INDEX @idx_badge_subscription_changes_purchase; DROP TABLE @badge_subscription_changes; +DROP INDEX @idx_badge_invoices_purchase; DROP TABLE @badge_invoices; DROP TABLE @badge_purchases; DROP TABLE @subscription_charges; +DROP INDEX @idx_payments_provider_ref; +DROP INDEX @idx_payments_invoice; DROP TABLE @payments; DROP TABLE @invoices; DROP TABLE @badge_offers; @@ -218,11 +229,34 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL; + +CREATE TABLE badge_code_redemptions( + badge_code_redemption_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + code TEXT NOT NULL, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(code) +); + +CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_code_redemption_id INTEGER REFERENCES badge_code_redemptions; + +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(badge_code_redemption_id); |] down_m20261001_user_badges :: Query down_m20261001_user_badges = [sql| +DROP INDEX idx_badge_purchases_code_redemption; +DROP INDEX idx_badge_purchases_user; ALTER TABLE users DROP COLUMN shown_badge_id; |] <> badgeSchemaDown "" + <> [sql| +DROP INDEX idx_badge_code_redemptions_user; +DROP TABLE badge_code_redemptions; +|]