mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
core: fix badge catalog overcharge and double-fetch
This commit is contained in:
@@ -15,8 +15,6 @@ module BadgeService.Catalog
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Exception (evaluate)
|
||||
import Control.Monad (void)
|
||||
import Data.List (find)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time.Clock (UTCTime, getCurrentTime)
|
||||
@@ -134,7 +132,7 @@ offerTotal BadgePrice {monthPrice = CurrencyAmount monthPriceMinor} Nothing =
|
||||
offerTotal BadgePrice {monthPrice = CurrencyAmount monthPriceMinor} (Just BadgeOffer {months, discount}) =
|
||||
CurrencyAmount <$> case discount of
|
||||
ODFreeMonths freeMonths -> (\m -> fromIntegral m * monthPriceMinor) <$> chargeableMonths months freeMonths
|
||||
ODDiscount percent -> Just ((fromIntegral months * monthPriceMinor * fromIntegral (100 - percent)) `div` 100)
|
||||
ODDiscount percent -> (\pct -> (fromIntegral months * monthPriceMinor * fromIntegral (100 - pct)) `div` 100) <$> discountedPercent percent
|
||||
|
||||
-- | months - freeMonths, but only once it's known safe: a bare 'Word8' subtraction is
|
||||
-- unsigned and unguarded, so an offer with freeMonths >= months (a typo, a future
|
||||
@@ -155,6 +153,21 @@ chargeableMonths months freeMonths
|
||||
| freeMonths >= months = Nothing
|
||||
| otherwise = Just (months - freeMonths)
|
||||
|
||||
-- | @100 - percent@, but only once it's known safe: the sibling of 'chargeableMonths', same
|
||||
-- hazard. A bare 'Word8' subtraction is unsigned and unguarded, so an offer with @percent >
|
||||
-- 100@ (a typo, a future repricing) would wrap (@100 - 101 :: Word8 == 255@) and this
|
||||
-- money-computing module would hand out a 2.55x overcharge instead of a crash or a refusal —
|
||||
-- worse than 'chargeableMonths'' hazard, since a wrap on the discount side inflates the
|
||||
-- price rather than reading as merely "unavailable". 'Store.decodeDiscount' does not range-
|
||||
-- check @percent@ on the way in, so a malformed row can reach this. @percent > 100@ isn't a
|
||||
-- value to compute a (wrong) discount for at all, so this answers 'Nothing' the same way
|
||||
-- 'chargeableMonths' does, for the same reason (§9): one bad row must cost one unpriced
|
||||
-- offer, never a request thread and never a wrong charge.
|
||||
discountedPercent :: Word8 -> Maybe Word8
|
||||
discountedPercent percent
|
||||
| percent > 100 = Nothing
|
||||
| otherwise = Just percent
|
||||
|
||||
-- | Fills every offer's 'total' (A2) with 'offerTotal' applied to that offer's pinned
|
||||
-- price. Overwrites unconditionally, so it is idempotent to call again. It is a total
|
||||
-- function: an offer whose price isn't found in the given catalog (which shouldn't happen,
|
||||
@@ -188,17 +201,21 @@ seedCatalog st = do
|
||||
mapM_ (insertPrice db) prices
|
||||
mapM_ (insertOffer db) offers
|
||||
where
|
||||
-- Every seeded offer is pinned to a price (see 'defaultCatalog'), so a 'Nothing' total
|
||||
-- here cannot mean "unpinned" -- it can only mean the offer is not chargeable at all
|
||||
-- (freeMonths >= months). 'chargeableMonths' no longer says so with 'error', because a
|
||||
-- request thread must not die of it (§9), so startup has to make the check itself or
|
||||
-- nothing would: a bad default catalog would seed silently and every client would see
|
||||
-- that offer as unavailable forever.
|
||||
-- Rejects 'total = Nothing' unconditionally, whatever the reason: not chargeable
|
||||
-- (freeMonths >= months, discount > 100) or genuinely unpinned. Every seeded offer is
|
||||
-- pinned to a price today (see 'defaultCatalog'), so only the first reason is reachable
|
||||
-- right now -- but this is stricter than the predecessor this replaced, which accepted
|
||||
-- an unpinned offer's 'Nothing' unconditionally (§9: a real behaviour change, recorded
|
||||
-- there since a future unpinned *default* offer would now fail startup rather than seed
|
||||
-- with no total). 'chargeableMonths'/'discountedPercent' no longer say "impossible offer"
|
||||
-- with 'error', because a request thread must not die of it (§9), so startup has to make
|
||||
-- the check itself or nothing would: a bad default catalog would seed silently and every
|
||||
-- client would see that offer as unavailable forever.
|
||||
requireTotal BadgeOffer {offerId = BadgeOfferId oid, total = Nothing} =
|
||||
ioError . userError $
|
||||
"seedCatalog: offer " <> T.unpack oid
|
||||
<> " has no chargeable total (freeMonths >= months, or no pinned price)"
|
||||
requireTotal BadgeOffer {total = Just (CurrencyAmount amount)} = void $ evaluate amount
|
||||
<> " has no chargeable total (freeMonths >= months, discount > 100, or no pinned price)"
|
||||
requireTotal BadgeOffer {total = Just _} = pure ()
|
||||
|
||||
insertPrice :: DB.Connection -> BadgePrice -> IO ()
|
||||
insertPrice db BadgePrice {priceId = BadgePriceId pid, badgeType, monthPrice = CurrencyAmount amt, currency, status, createdAt} =
|
||||
|
||||
@@ -568,7 +568,8 @@ data BadgeServiceEnv = BadgeServiceEnv
|
||||
-- control against a distributed guesser -- see 'signerFailureBucket''s Haddock.
|
||||
globalFailureBucket :: TVar TokenBucket,
|
||||
-- | B5 decision 5: bounds unsigned 'getBadgeCatalog' (no signer to key on), 600/hour,
|
||||
-- burst 600, service-wide. Unused until B6 wires 'getBadgeCatalog' itself.
|
||||
-- burst 600, service-wide. Spent by 'BadgeService.Service.handleGetBadgeCatalog' (B6) via
|
||||
-- 'takeCatalogBucket', on every unsigned request.
|
||||
catalogBucket :: TVar TokenBucket
|
||||
}
|
||||
|
||||
|
||||
@@ -195,11 +195,16 @@ handleServiceRequest bsEnv cc User {userId} reqId signerKey reqData = do
|
||||
-- (encoding it and demanding the whole encoded length) before returning, all still inside the
|
||||
-- 'catch' below. Laziness would otherwise let an exception escape uncaught: 'action' finishing
|
||||
-- and returning a lazily-built 'BadgeServiceResponse' does not itself throw, even if a field
|
||||
-- deep inside is an unevaluated 'error' thunk (as 'BadgeService.Catalog.chargeableMonths'
|
||||
-- produces for a malformed offer, once B6\/B7 wire catalog totals into request handling) --
|
||||
-- that thunk would only be forced later, by 'sendChatCmd''s own JSON encoding, OUTSIDE this
|
||||
-- function, where nothing would catch it. Forcing the encoding here, inside the 'catch',
|
||||
-- is what makes this a genuine catch-all rather than one that only covers IO exceptions.
|
||||
-- deep inside is an unevaluated 'error' thunk -- that thunk would only be forced later, by
|
||||
-- 'sendChatCmd''s own JSON encoding, OUTSIDE this function, where nothing would catch it.
|
||||
-- Forcing the encoding here, inside the 'catch', is what makes this a genuine catch-all
|
||||
-- rather than one that only covers IO exceptions. Nothing in the currently implemented
|
||||
-- commands is known to build such a thunk -- B6 closed the one concrete hazard this used to
|
||||
-- cite by name, 'BadgeService.Catalog.chargeableMonths', which now returns 'Maybe' instead of
|
||||
-- calling 'error' -- so this guards against a partial function in some future response field,
|
||||
-- not a specific one today; 'testBadgeServiceCatchAllContainsPureException' proves the
|
||||
-- mechanism directly against a constructed thunk, since no real one currently exists to test
|
||||
-- against.
|
||||
--
|
||||
-- 'processQueuedRequests' is a single-threaded 'forever' loop: an exception that got out of
|
||||
-- here would kill the service for every user, not just fail the one request that caused it.
|
||||
@@ -246,45 +251,52 @@ dispatchRequest bsEnv signerKey reqData =
|
||||
| otherwise ->
|
||||
checkSignerRecord bsEnv request purchaseKey >>= \case
|
||||
Left err -> pure $ errorResponse err Nothing Nothing
|
||||
Right () -> dispatchCommand bsEnv purchaseKey request
|
||||
Right purchaseRow -> dispatchCommand bsEnv purchaseKey purchaseRow request
|
||||
|
||||
decodeRequest :: J.Object -> Either String BadgeServiceRequest
|
||||
decodeRequest = JT.parseEither J.parseJSON . J.Object
|
||||
|
||||
-- | The signer\/record precondition, applied to every command before dispatch:
|
||||
-- * 'getBadgeCatalog' may be unsigned (no key at all); nothing further is required of it.
|
||||
-- | The signer\/record precondition, applied to every command before dispatch. Returns the
|
||||
-- looked-up row on success ('Nothing' when none was required), so a handler that needs it
|
||||
-- (B6's 'handleGetBadgeCatalog'; B7's future 'issueBadge') reads it once here rather than
|
||||
-- looking it up again itself in a second transaction:
|
||||
-- * 'getBadgeCatalog' may be unsigned (no key at all); nothing further is required of it,
|
||||
-- and there is no row to return.
|
||||
-- * 'purchaseBadge' requires a signature but NOT a pre-existing record -- an unknown key is
|
||||
-- the normal first-purchase case, because B7 is what creates the purchase row. Getting
|
||||
-- this inverted would make first purchases impossible.
|
||||
-- this inverted would make first purchases impossible. No row is looked up, so none is
|
||||
-- returned even though the request is signed.
|
||||
-- * every other command, including a *signed* 'getBadgeCatalog', requires both a signature
|
||||
-- and an existing purchase row: no key at all is 'bad_request' (nothing was signed), an
|
||||
-- unknown key is 'unknown_purchase_key'.
|
||||
checkSignerRecord :: BadgeServiceEnv -> BadgeServiceCommand -> Maybe C.PublicKeyEd25519 -> IO (Either BadgeServiceErrorCode ())
|
||||
checkSignerRecord _ BSCGetBadgeCatalog Nothing = pure $ Right ()
|
||||
-- unknown key is 'unknown_purchase_key'; found, the row is returned.
|
||||
checkSignerRecord :: BadgeServiceEnv -> BadgeServiceCommand -> Maybe C.PublicKeyEd25519 -> IO (Either BadgeServiceErrorCode (Maybe BadgePurchaseRow))
|
||||
checkSignerRecord _ BSCGetBadgeCatalog Nothing = pure $ Right Nothing
|
||||
checkSignerRecord bsEnv BSCGetBadgeCatalog (Just key) = requirePurchaseRecord bsEnv key
|
||||
checkSignerRecord _ (BSCPurchaseBadge {}) Nothing = pure $ Left BSEBadRequest
|
||||
checkSignerRecord _ (BSCPurchaseBadge {}) (Just _) = pure $ Right ()
|
||||
checkSignerRecord _ (BSCPurchaseBadge {}) (Just _) = pure $ Right Nothing
|
||||
checkSignerRecord _ _ Nothing = pure $ Left BSEBadRequest
|
||||
checkSignerRecord bsEnv _ (Just key) = requirePurchaseRecord bsEnv key
|
||||
|
||||
requirePurchaseRecord :: BadgeServiceEnv -> C.PublicKeyEd25519 -> IO (Either BadgeServiceErrorCode ())
|
||||
requirePurchaseRecord :: BadgeServiceEnv -> C.PublicKeyEd25519 -> IO (Either BadgeServiceErrorCode (Maybe BadgePurchaseRow))
|
||||
requirePurchaseRecord BadgeServiceEnv {store} key =
|
||||
withServiceTransaction store (\db -> getPurchaseByKey db key) >>= \case
|
||||
Right (Just _) -> pure $ Right ()
|
||||
Right (Just row) -> pure $ Right (Just row)
|
||||
Right Nothing -> pure $ Left BSEUnknownPurchaseKey
|
||||
Left _ -> pure $ Left BSEInternal
|
||||
|
||||
-- | Dispatch on the command, once the signer\/record precondition already passed.
|
||||
-- 'getBadgeInvoice', 'upgradeBadgeSubscription' and 'pauseBadge' are out of scope (decision 5
|
||||
-- \/ §6) and always 'bad_request'; 'getBadgeCatalog' and 'issueBadge' are B6\/B7's commands and
|
||||
-- answer 'internal' \"not implemented\" until those steps land.
|
||||
dispatchCommand :: BadgeServiceEnv -> Maybe C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse
|
||||
dispatchCommand _ _ (BSCGetBadgeInvoice {}) = pure $ errorResponse BSEBadRequest Nothing Nothing
|
||||
dispatchCommand _ _ (BSCUpgradeBadgeSubscription {}) = pure $ errorResponse BSEBadRequest Nothing Nothing
|
||||
dispatchCommand _ _ BSCPauseBadge = pure $ errorResponse BSEBadRequest Nothing Nothing
|
||||
dispatchCommand bsEnv purchaseKey BSCGetBadgeCatalog = handleGetBadgeCatalog bsEnv purchaseKey
|
||||
dispatchCommand _ _ (BSCIssueBadge {}) = pure notImplemented
|
||||
dispatchCommand bsEnv purchaseKey (BSCPurchaseBadge {payment}) = dispatchPurchase bsEnv purchaseKey payment
|
||||
-- \/ §6) and always 'bad_request'; 'issueBadge' is B7's command and answers 'internal'
|
||||
-- \"not implemented\" until that step lands. 'getBadgeCatalog' (B6) and, later, 'issueBadge'
|
||||
-- are the two commands that use the 'Maybe' 'BadgePurchaseRow' 'checkSignerRecord' already
|
||||
-- looked up; every other clause below ignores it.
|
||||
dispatchCommand :: BadgeServiceEnv -> Maybe C.PublicKeyEd25519 -> Maybe BadgePurchaseRow -> BadgeServiceCommand -> IO BadgeServiceResponse
|
||||
dispatchCommand _ _ _ (BSCGetBadgeInvoice {}) = pure $ errorResponse BSEBadRequest Nothing Nothing
|
||||
dispatchCommand _ _ _ (BSCUpgradeBadgeSubscription {}) = pure $ errorResponse BSEBadRequest Nothing Nothing
|
||||
dispatchCommand _ _ _ BSCPauseBadge = pure $ errorResponse BSEBadRequest Nothing Nothing
|
||||
dispatchCommand bsEnv _ purchaseRow BSCGetBadgeCatalog = handleGetBadgeCatalog bsEnv purchaseRow
|
||||
dispatchCommand _ _ _ (BSCIssueBadge {}) = pure notImplemented
|
||||
dispatchCommand bsEnv purchaseKey _ (BSCPurchaseBadge {payment}) = dispatchPurchase bsEnv purchaseKey payment
|
||||
|
||||
-- | 'checkSignerRecord' already requires a signature for every 'purchaseBadge', so
|
||||
-- 'purchaseKey' is 'Just' here in every reachable case; the 'Nothing' clause only keeps this
|
||||
@@ -312,38 +324,44 @@ dispatchPurchase _ _ (SPReceipt {}) = pure $ errorResponse BSEBadRequest Nothing
|
||||
|
||||
-- | Answers the catalog, and for a signed request the signer's statement as well.
|
||||
--
|
||||
-- Takes the 'Maybe' 'BadgePurchaseRow' 'checkSignerRecord' already looked up ('Nothing' for
|
||||
-- an unsigned request, 'Just' the row for a signed one -- an unknown signed key never reaches
|
||||
-- here, 'checkSignerRecord' already answered 'unknown_purchase_key'), rather than a key: a
|
||||
-- second lookup by key here would open a second transaction reading the same row.
|
||||
--
|
||||
-- Unsigned requests spend a token from the service-wide catalog bucket first (B5 decision 5):
|
||||
-- there is no signer to key on and no failure to count, so the request itself is the only
|
||||
-- thing that can be bounded. A signed request is not subject to it -- 'checkSignerRecord'
|
||||
-- has already required an existing purchase row, which is the bound.
|
||||
-- thing that can be bounded. A signed request is not subject to it -- the row already in hand
|
||||
-- is the bound.
|
||||
--
|
||||
-- Both halves are read in ONE transaction, and it is a writing one: healing the ledger
|
||||
-- (@advance now@) persists its @debit(lapse)@ row in the same transaction that then reads the
|
||||
-- statement back, so the balance a client is told is the balance the database holds. This is
|
||||
-- the only read command that writes (RPC "Statement and balance").
|
||||
handleGetBadgeCatalog :: BadgeServiceEnv -> Maybe C.PublicKeyEd25519 -> IO BadgeServiceResponse
|
||||
handleGetBadgeCatalog bsEnv@BadgeServiceEnv {store, now} signerKey = case signerKey of
|
||||
handleGetBadgeCatalog :: BadgeServiceEnv -> Maybe BadgePurchaseRow -> IO BadgeServiceResponse
|
||||
handleGetBadgeCatalog bsEnv@BadgeServiceEnv {store, now} purchaseRow = case purchaseRow of
|
||||
Nothing ->
|
||||
takeCatalogBucket bsEnv >>= \case
|
||||
Left retryAfter -> pure $ errorResponse BSERateLimited Nothing (Just retryAfter)
|
||||
Right () -> respond Nothing
|
||||
Just key -> respond (Just key)
|
||||
Just row -> respond (Just row)
|
||||
where
|
||||
respond key = do
|
||||
respond row = do
|
||||
now' <- now
|
||||
withServiceTransaction store (catalogTxn now' key) >>= \case
|
||||
withServiceTransaction store (catalogTxn now' row) >>= \case
|
||||
Left e -> do
|
||||
logError $ "getBadgeCatalog failed: " <> tshow e
|
||||
pure $ errorResponse BSEInternal Nothing Nothing
|
||||
Right (catalog, badgeStatement) -> do
|
||||
logUnpricedOffers catalog
|
||||
pure BSPBadgeCatalog {catalog, badgeStatement}
|
||||
catalogTxn now' key db = do
|
||||
catalogTxn now' row db = do
|
||||
-- catalogTotals is applied to what the DATABASE holds, never to Catalog.hs's defaults,
|
||||
-- so a price the operator deprecated or disabled is reflected without a rebuild
|
||||
-- (decision 8): the site, the RPC catalog and the charge all read this one result.
|
||||
catalog <- catalogTotals <$> getActiveCatalog db
|
||||
statement <- mapM (purchaseStatement now' db) key
|
||||
-- getBadgeCatalog carries no cursor, so this is always the full ledger (Nothing).
|
||||
statement <- mapM (purchaseStatement now' db Nothing) row
|
||||
pure (catalog, statement)
|
||||
|
||||
-- | An offer that is pinned to a price the catalog also returned, yet still has no total,
|
||||
@@ -360,7 +378,9 @@ logUnpricedOffers BadgeCatalog {prices, offers} =
|
||||
logWarn $ "catalog offer " <> oid <> " has a pinned price but no chargeable total"
|
||||
_ -> pure ()
|
||||
|
||||
-- | Heals the purchase's ledger to @now@, then reads the whole of it back as a statement.
|
||||
-- | Heals the purchase's ledger to @now@, then reads it back as a statement -- the whole
|
||||
-- ledger when @sinceEntryId@ is 'Nothing', or only entries strictly after it otherwise
|
||||
-- (matches 'getLedgerSince'\'s own semantics).
|
||||
--
|
||||
-- @advance@ is run against the last stored entry's state and, when it yields months, ONE
|
||||
-- @debit(lapse)@ row is written for them (B2's calling convention: one row, whatever @k@ is).
|
||||
@@ -368,19 +388,23 @@ logUnpricedOffers BadgeCatalog {prices, offers} =
|
||||
-- balance to lapse from -- so it returns an empty statement rather than inventing an opening
|
||||
-- entry.
|
||||
--
|
||||
-- 'previousEntryId' is 'Nothing': 'getBadgeCatalog' carries no cursor, so this is always the
|
||||
-- full ledger, which is what that field's absence means.
|
||||
purchaseStatement :: UTCTime -> DB.Connection -> C.PublicKeyEd25519 -> ExceptT ServiceError IO BadgeStatement
|
||||
purchaseStatement now' db key = do
|
||||
purchase <- getPurchaseByKey db key
|
||||
case purchase of
|
||||
-- unreachable: checkSignerRecord already required the row for a signed request. Refused
|
||||
-- rather than answered with an empty statement, which would look like a real ledger.
|
||||
Nothing -> throwError $ SEDecodeError "getBadgeCatalog: signer has no purchase row"
|
||||
Just BadgePurchaseRow {badgePurchaseId} -> do
|
||||
healLedger now' db badgePurchaseId
|
||||
entries <- mapM (liftEither' . toStatementEntry) =<< getLedgerSince db badgePurchaseId Nothing
|
||||
pure BadgeStatement {entries, previousEntryId = Nothing}
|
||||
-- Takes the purchase row directly rather than a key: every caller has already looked it up
|
||||
-- once (B6's 'handleGetBadgeCatalog' via 'checkSignerRecord'; B7's future 'issueBadge' the
|
||||
-- same way), and looking it up again here would open a second transaction reading the same
|
||||
-- row -- so there is no "signer has no purchase row" case to handle any more.
|
||||
--
|
||||
-- @sinceEntryId@ is also threaded through rather than hardcoded, so a future cursor-carrying
|
||||
-- caller (B7) can reuse this function instead of duplicating it. 'getBadgeCatalog' (B6)
|
||||
-- always passes 'Nothing' -- it carries no cursor -- for which 'previousEntryId' being
|
||||
-- 'Nothing' below is exactly correct (RPC: "absent for the full ledger"). 'previousEntryId'
|
||||
-- is meant to echo the client's *asserted* (wire, 'Text') entryId, which this function is
|
||||
-- never given, only the resolved 'Int64' to query on -- a real cursor caller needs to carry
|
||||
-- that wire value through separately to fill it in for real; nothing today constructs one.
|
||||
purchaseStatement :: UTCTime -> DB.Connection -> Maybe Int64 -> BadgePurchaseRow -> ExceptT ServiceError IO BadgeStatement
|
||||
purchaseStatement now' db sinceEntryId BadgePurchaseRow {badgePurchaseId} = do
|
||||
healLedger now' db badgePurchaseId
|
||||
entries <- mapM (liftEither' . toStatementEntry) =<< getLedgerSince db badgePurchaseId sinceEntryId
|
||||
pure BadgeStatement {entries, previousEntryId = Nothing}
|
||||
where
|
||||
liftEither' = either throwError pure
|
||||
|
||||
|
||||
@@ -1344,7 +1344,9 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
|
||||
- **A2 — three more `Int64` id fields need the same correction the step already mandates.** The step lists `BadgePurchase.paymentId`, `BadgePayment.paymentId` and `BadgeIssuance.issuanceId`. An audit of `badges-rpc.schema.json` against all four modules found the same defect in three further places, all `TEXT` columns typed as `Int64`: `StatementCreditType.SCCharge {chargeId}` (`Badges/Service.hs:163`, against `subscription_charges.charge_id TEXT NOT NULL PRIMARY KEY`), and `BadgeCharge.chargeId` and `BadgeCharge.paymentId` (`Badges/Types.hs:163-164`). `SCCharge` is the load-bearing one: it is a wire type whose `taggedObjectJSON` instance A2 writes, the schema declares `chargeId` as `string` (`badges-rpc.schema.json:230`), and Aeson would encode an `Int64` as a JSON number — so leaving it ships a payload that fails its own schema. Its sibling `SCPayment` already carries `Maybe InvoiceId`, a newtype over `Text`. A2 corrects all six.
|
||||
- **`LedgerCreditType.CTPayment {invoiceId :: Int64}` and `CTCharge {chargeId :: Int64}` are wrong against their columns but are marked `-- confirmed`. OPEN — needs a decision, not a mechanical fix.** `invoices.invoice_id` and `subscription_charges.charge_id` are both `TEXT`. These are the DB-side twins of the wire types above, and `CTTransferIn {fromPurchaseId :: Maybe Int64}` beside them is correct because `from_purchase_id` really is `INTEGER`. A2 does not touch them: they sit outside its tagged-sum list, and altering a type someone marked confirmed is above a mechanical step. C1's `insertLedgerEntries` is the first code that would persist them, so this must be settled before C1.
|
||||
- **A4 — `offerTotal` calls `error` on an impossible offer, which B6 and D4 must not let reach a request thread.** `chargeableMonths` (`BadgeService/Catalog.hs`) rejects `freeMonths >= months` with `error` rather than wrapping a `Word8` subtraction, and `seedCatalog` forces it at startup so a bad catalog kills the process before the service accepts traffic. That fences it for Phase A, where `seedCatalog` is the only writer. It stops being fenced the moment `offerTotal`/`catalogTotals` run inside request handling over rows read from the database, which is B6 (`getBadgeCatalog`) and D4 (`/api/catalog`). The bot's `processQueuedRequests` is a single-threaded `forever` loop (`BadgeService/Service.hs:96-99`), so an uncaught `error` there would take the whole service down for every user rather than failing one request — strictly worse than the mispricing the guard prevents. Before B6, either give `BadgeOffer` a smart constructor so `freeMonths >= months` is unrepresentable, or catch at the request boundary so the blast radius is one response.
|
||||
- **B6 — resolves the A4 `offerTotal`/`error` hazard above with a third option: a typed absence, not either option this plan named.** Neither a smart constructor (would change `BadgeOffer`'s shape and every construction site, including the seeded defaults) nor a request-boundary catch (relies on `runHandler`'s catch-all reaching every caller, including D4's future HTTP path, which does not share that catch-all) was taken. Instead `chargeableMonths :: Word8 -> Word8 -> Maybe Word8` and `offerTotal :: BadgePrice -> Maybe BadgeOffer -> Maybe CurrencyAmount` (`BadgeService/Catalog.hs`): `freeMonths >= months` now answers `Nothing`, which A2 already defines on the wire as "no computed total, render unavailable" — so the blast radius of one bad row is one offer, not one response and not one process, and the fix is shared by every caller of `offerTotal`, present or future, not just ones inside a `catch`. `seedCatalog` still fails the process at startup, by name (`requireTotal`), for a bad *default* catalog, so the Phase A guarantee is unchanged. `handleGetBadgeCatalog` additionally logs (`logUnpricedOffers`) when a *database* row reaches this state, since nothing else would ever say why a live offer reads as unavailable.
|
||||
- **B6 — resolves the A4 `offerTotal`/`error` hazard above with a third option: a typed absence, not either option this plan named.** Neither a smart constructor (would change `BadgeOffer`'s shape and every construction site, including the seeded defaults) nor a request-boundary catch (relies on `runHandler`'s catch-all reaching every caller, including D4's future HTTP path, which does not share that catch-all) was taken. Instead `chargeableMonths :: Word8 -> Word8 -> Maybe Word8` and `offerTotal :: BadgePrice -> Maybe BadgeOffer -> Maybe CurrencyAmount` (`BadgeService/Catalog.hs`): `freeMonths >= months` now answers `Nothing`, which A2 already defines on the wire as "no computed total, render unavailable" — so the blast radius of one bad row is one offer, not one response and not one process, and the fix is shared by every caller of `offerTotal`, present or future, not just ones inside a `catch`. `seedCatalog` still fails the process at startup, by name (`requireTotal`), for a bad *default* catalog, so the Phase A guarantee is unchanged. `handleGetBadgeCatalog` additionally logs (`logUnpricedOffers`) when a *database* row reaches this state, since nothing else would ever say why a live offer reads as unavailable. **Fix round 1** found the same hazard on `ODDiscount`'s sibling arm, which the original pass missed: `100 - percent` on a bare `Word8` wraps for `percent > 100` (a `Store.decodeDiscount` value is not range-checked on the way in), producing a 2.55x *overcharge* rather than a crash — worse than the `freeMonths` case, since it inflates the price instead of merely reading as unavailable. `discountedPercent :: Word8 -> Maybe Word8` closes it the same way, `percent > 100 -> Nothing`.
|
||||
- **B6 fix round 1 — `seedCatalog`'s `requireTotal` is stricter than the `forceTotal` it replaced: an unpinned offer's `total = Nothing` is now also rejected at startup, not just an unchargeable one.** The predecessor (`forceTotal`, pre-B6) accepted `total = Nothing` unconditionally — its Haddock read "an offer whose price isn't found... gets `total = Nothing` rather than a crash, same as an unpinned offer" — because at the time nothing distinguished "unpinned" from "not chargeable" and neither was thought worth failing startup over. B6's `requireTotal` rejects `total = Nothing` for either reason: correct for today's `defaultCatalog` (every seeded offer is pinned, so only "not chargeable" is reachable), but a real behaviour change per §4 rule 6 — a future unpinned *default* offer would now fail the service at startup rather than silently seed with no total. Recorded rather than re-litigated, since the stricter behaviour is the one actually wanted (an unpinned default offer is exactly the kind of bad row `seedCatalog` should catch by name, not seed silently).
|
||||
- **B6 — `BadgeService/Config.hs` is touched, outside the step's stated file list (`Service.hs`, `tests/Bots/BadgeServiceTests.hs`).** `takeCatalogBucket` (the peek-and-debit STM action for the unsigned-`getBadgeCatalog` bucket B5 added but never wired) had to be added there to satisfy the Verify line's throttle case — B5 built the bucket itself but not a way to spend from it, and that action belongs beside `checkFailureBuckets`/`debitFailureBuckets`, the other bucket operations, not in `Service.hs`. §4 rule 6 was applied to the `Catalog.hs` deviation (the two entries above) but missed this one in the original pass; recorded here on the same rule.
|
||||
- **§4 — the stated build command did not work; corrected in place.** `cabal build simplex-chat simplex-badge-service` fails with `Ambiguous target 'simplex-chat'`, because `simplex-chat` names both a library and an executable component. It is now `cabal build lib:simplex-chat exe:simplex-chat simplex-badge-service`, which was run and succeeds. The test command beside it was correct as written and passes: 41 examples, 0 failures across both `Supporter badges` and `Badge service`.
|
||||
- **B1 — two vocabularies are now spelled only in `BadgeService/Store.hs`, with nothing tying them to a future codec.** The ledger's `entry_credit_type`/`entry_debit_type` values (`support`, `opening`, `transfer_in`, …) and the payment provider literal `code` are written as bare strings, because no `ToField`/`FromField` instance exists for `LedgerCreditType`, `LedgerDebitType` or `PaymentProvider` anywhere in the repo. Neither is a *duplicate* spelling today, so neither is a defect. Both become one the moment a later step adds a codec: D0, E2 and F1 need a real `PaymentProvider` encoding, and whoever resolves the `LedgerCreditType` question above will need the entry-type spellings to match what B1 already wrote. Reuse these spellings rather than inventing a second set, and prefer deriving both directions from one instance, as `BadgePurchaseStatus` and `BadgeItemStatus` do.
|
||||
- **B5 — the per-signer bucket sweeper is built and tested but nothing schedules it. B7 must wire it to a timer. OPEN.** The original hazard is closed: `peekSignerBucket` no longer creates an entry for an unseen key, so a freshly minted keypair costs nothing, and an entry is created only by a classified failure — which also spends a token from the single shared global bucket, capping new entries per window at that bucket's capacity however many keys an attacker mints. `sweepSignerBuckets` evicts recovered entries and `sweepSignerBucketsIO` takes the injectable clock so eviction is provable without sleeping. What is missing is a caller: nothing runs the sweep on an interval, so the map only shrinks when something asks it to. This costs nothing during B5, where `debitFailureBuckets` has no caller at all and the map is provably empty on every reachable path. **B7 is the first step that makes a redemption fail, so B7 owns wiring the sweep onto a timer** — a third arm of the service's `raceAny_` alongside the bot and the reconciliation pass is the natural place.
|
||||
|
||||
@@ -136,6 +136,7 @@ badgeServiceTests = do
|
||||
it "should price 3 months at 2x and 12 months at 6x the monthly price" testBadgeCatalogOfferTotal
|
||||
it "should fill total for every seeded offer" testBadgeCatalogTotalsFillsSeededOffers
|
||||
it "should reject an offer with freeMonths >= months instead of wrapping" testBadgeCatalogOfferTotalRejectsBadFreeMonths
|
||||
it "should reject an offer with discount > 100 instead of wrapping into an overcharge" testBadgeCatalogOfferTotalRejectsBadDiscount
|
||||
it "should encode BadgeItemStatus on the wire as active/deprecated/disabled" testBadgeItemStatusJsonWireFormat
|
||||
it "should fail to start on a missing config file, naming the file" testBadgeServiceConfigMissingFile
|
||||
it "should fail to start on an unparsable value, naming the key" testBadgeServiceConfigUnparsableValue
|
||||
@@ -646,6 +647,28 @@ testBadgeCatalogOfferTotalRejectsBadFreeMonths _ps = do
|
||||
}
|
||||
offerTotal price (Just badOffer) `shouldBe` Nothing
|
||||
|
||||
-- The sibling hazard, on the ODDiscount side: a Word8 subtraction of percent from 100 is
|
||||
-- unsigned and unguarded, so an offer with percent > 100 (a typo, a future repricing) would
|
||||
-- wrap silently (100 - 101 :: Word8 == 255) and hand out a 2.55x OVERCHARGE, worse than the
|
||||
-- freeMonths hazard above since it inflates the price instead of merely reading as
|
||||
-- unavailable. offerTotal must instead answer Nothing, the same way, for the same reason.
|
||||
testBadgeCatalogOfferTotalRejectsBadDiscount :: HasCallStack => TestParams -> IO ()
|
||||
testBadgeCatalogOfferTotalRejectsBadDiscount _ps = do
|
||||
now <- getCurrentTime
|
||||
let BadgeCatalog {prices} = defaultCatalog now
|
||||
price@BadgePrice {priceId} = fromJust $ find (\BadgePrice {badgeType} -> badgeType == BTSupporter) prices
|
||||
badOffer =
|
||||
BadgeOffer
|
||||
{ offerId = BadgeOfferId "test-bad-offer-discount-gt-100",
|
||||
priceId = Just priceId,
|
||||
months = 3,
|
||||
discount = ODDiscount 101,
|
||||
status = BISActive,
|
||||
createdAt = now,
|
||||
total = Nothing
|
||||
}
|
||||
offerTotal price (Just badOffer) `shouldBe` Nothing
|
||||
|
||||
-- BadgeItemStatus's JSON crosses the wire (BadgePrice/BadgeOffer.status), so pinning finding
|
||||
-- 2's TextEncoding-derived encoding to what the earlier TH-derived instance produced proves
|
||||
-- the change is invisible on the wire, not just asserted to be.
|
||||
@@ -831,7 +854,11 @@ testBadgeServiceCompleteConfigStarts ps@TestParams {tmpPath} =
|
||||
-- A disabled price (and every offer pinned to it) must be absent from the RPC catalog, while
|
||||
-- a deprecated price (and its offers) must still be present -- getActiveCatalog's own
|
||||
-- invariant (already proved at the store level by testBadgeStoreSetPriceStatusDisabled),
|
||||
-- surfaced here through the live RPC path handleGetBadgeCatalog actually calls.
|
||||
-- surfaced here through the live RPC path handleGetBadgeCatalog actually calls. Also asserts
|
||||
-- decision 8 at the wire boundary: every remaining offer's total is populated, so a client
|
||||
-- never has to (and can't, since it doesn't have the prices) compute one itself -- deleting
|
||||
-- the handler's catalogTotals call would still pass every other assertion in this test file
|
||||
-- without this one.
|
||||
testBadgeServiceGetCatalogDisabledDeprecated :: HasCallStack => TestParams -> IO ()
|
||||
testBadgeServiceGetCatalogDisabledDeprecated ps = do
|
||||
priceIdsRef <- newIORef Nothing
|
||||
@@ -858,6 +885,7 @@ testBadgeServiceGetCatalogDisabledDeprecated ps = do
|
||||
any (\BadgePrice {priceId} -> priceId == deprecatedId) prices `shouldBe` True
|
||||
any (\BadgeOffer {priceId} -> priceId == Just disabledId) offers `shouldBe` False
|
||||
any (\BadgeOffer {priceId} -> priceId == Just deprecatedId) offers `shouldBe` True
|
||||
all (\BadgeOffer {total} -> isJust total) offers `shouldBe` True
|
||||
other -> expectationFailure $ "expected BSPBadgeCatalog, got: " <> show other
|
||||
|
||||
-- getBadgeCatalog applies checkSignerRecord like every other signed command (B5): a signed
|
||||
|
||||
Reference in New Issue
Block a user