core: renew badges monthly and alert when support ends (#7448)

This commit is contained in:
spaced4ndy
2026-09-09 14:31:05 +00:00
committed by GitHub
parent c623f4284c
commit f9bab17643
30 changed files with 2901 additions and 220 deletions
@@ -2,6 +2,7 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
module BadgeService.Service
( ServiceState (..),
@@ -10,6 +11,8 @@ module BadgeService.Service
checkIssuerKey,
badgeService,
badgeServiceCLI,
badgeServiceResponse,
badgeErrorRetryAfter,
IssueCodeOpts (..),
issueBadgeCode,
)
@@ -28,15 +31,16 @@ import qualified Data.Aeson.KeyMap as KM
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import Data.Char (isSpace)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.Maybe (fromMaybe)
import Data.Maybe (fromMaybe, maybeToList)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import Data.Time.Calendar (addDays, addGregorianMonthsClip)
import Data.Time.Calendar.WeekDate (toWeekDate)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Word (Word32)
import Simplex.Chat.Badges
import Simplex.Chat.Badges.Code
import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
import Simplex.Chat.Bot (initializeBotAddress')
@@ -224,7 +228,16 @@ responseObject r = case J.toJSON r of
_ -> KM.fromList [("type", J.String "error"), ("code", J.toJSON BSEInternal)]
errorResponse :: BadgeServiceErrorCode -> BadgeServiceResponse
errorResponse code = BSPError {code, message = Nothing, retryAfter = Nothing}
errorResponse code = BSPError {code, message = Nothing, retryAfter = badgeErrorRetryAfter code}
-- | Seconds, for the three codes badges-rpc.md marks transient. Every other code is terminal for
-- the command attempted - internal included, which would otherwise press a failing service.
badgeErrorRetryAfter :: BadgeServiceErrorCode -> Maybe Word32
badgeErrorRetryAfter = \case
BSEPaymentPending -> Just 300
BSEProviderUnavailable -> Just 300
BSERateLimited -> Just 60
_ -> Nothing
-- | The agent verified the signature, so sigKey is a key the sender holds - a purchaseKey that
@@ -239,6 +252,9 @@ badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData)
BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of
Just k -> redeemCode key cc k masterKey code
Nothing -> pure $ errorResponse BSEBadRequest
BSCIssueBadge {balance} -> case purchaseKey of
Just k -> issueBadgeCmd key cc k balance
Nothing -> pure $ errorResponse BSEBadRequest
-- every command but redeemBadgeCode needs a key the service already knows: that one
-- creates the purchase, so its key is unknown on a first redemption
_ -> case purchaseKey of
@@ -249,64 +265,115 @@ badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData)
Right False -> pure $ errorResponse BSEUnknownPurchaseKey
Left _ -> pure $ errorResponse BSEInternal
-- | The only clock the service reads, so a test can move both sides of a request together.
badgeNow :: ChatController -> IO UTCTime
badgeNow ChatController {config = ChatConfig {badgeCurrentTime}} = badgeCurrentTime
randomId :: ChatController -> IO T.Text
randomId cc = safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 (random cc))
-- | Neither value comes from the caller: the badge type is the entry's, the expiry is derived
-- from the period end it carries.
credentialForEntry :: BadgeIssuerKey -> BadgeMasterKey -> StatementEntry -> IO (Either String (StatementEntry, BadgeCredential))
credentialForEntry BadgeIssuerKey {keyIdx, secretKey} masterKey e@StatementEntry {balanceStartTs = periodEnd, balanceBadgeType} = do
let badgeInfo = BadgeInfo {badgeType = balanceBadgeType, badgeExpiry = endOfMondayAfter periodEnd, badgeExtra = ""}
fmap (e,) <$> issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo})
-- | Pairs the issued entry with the one before it, which the writer needs for the period start.
issuanceAfter :: StatementEntry -> (StatementEntry, BadgeCredential) -> (StatementEntry, StatementEntry, BadgeCredential)
issuanceAfter previous (issued, credential) = (previous, issued, credential)
credentialResponse :: Maybe BadgeCredential -> Maybe T.Text -> [StatementEntry] -> BadgeServiceResponse
credentialResponse credential previousEntryId entries =
BSPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}}
-- | Nothing is written until the credential is signed, so a signing failure leaves the code
-- unspent rather than spent with nothing behind it.
redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse
redeemCode BadgeIssuerKey {keyIdx, secretKey} cc purchaseKey masterKey codeText = case parseBadgeCode codeText of
redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText of
Nothing -> pure $ errorResponse BSECodeInvalid
Just code ->
withDB' "getBadgeCode" cc (`getBadgeCode` badgeCodeHash code) >>= \case
withDB "getBadgeCode" cc (readCode code) >>= \case
Left _ -> pure $ errorResponse BSEInternal
Right Nothing -> pure $ errorResponse BSECodeInvalid
Right (Just IssuedCode {badgeCodeId, badgeType, redemption}) -> case redeemedResponse redemption of
Just resp -> pure resp
Nothing -> do
now <- getCurrentTime
-- TODO [badges] the code's months are ignored until the ledger credits them
let periodEnd = addMonths 1 now
badgeInfo = BadgeInfo {badgeType, badgeExpiry = endOfSundayAfter periodEnd, badgeExtra = ""}
issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) >>= \case
Right (Left resp) -> pure resp
Right (Right IssuedCode {badgeCodeId, badgeType, months}) -> do
now <- badgeNow cc
(grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc
-- the purchase is created here, so there is no ledger to lapse
-- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the
-- months it adds are counted from a start already in the past
let granted = grantEntry now grantUuid months SCCode $ emptyEntry now badgeType
-- a grant of at least one month starting now always has a month to issue
case issueEntry now issueUuid granted of
Nothing -> pure $ errorResponse BSEInternal
Just issued -> credentialForEntry key masterKey issued >>= \case
Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal
Right credential -> do
issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 $ random cc)
let newRedemption =
NewBadgeCodeRedemption
{ badgeCodeId,
issuanceId,
purchaseKey,
masterKey,
badgeType,
credential,
periodStart = now,
periodEnd,
expiry = endOfSundayAfter periodEnd
}
Right signed -> do
-- re-read: a concurrent redemption may have landed while this one was signing
r <- withDB "writeCodeRedemption" cc $ \db ->
liftIO (getBadgeCode db $ badgeCodeHash code) >>= \case
Just IssuedCode {redemption = current} | Just resp <- redeemedResponse current -> pure resp
_ -> liftIO $ credentialResponse credential <$ writeCodeRedemption db newRedemption now
pure $ either (const $ errorResponse BSEInternal) id r
readCode code db >>= \case
Left resp -> pure resp
Right _ -> liftIO $ do
purchaseId <- createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now
appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed
entries_ <- getLedgerEntries db purchaseId 0
pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_
pure $ fromRight (errorResponse BSEInternal) r
where
-- one definition, used before signing and again inside the write transaction
redeemedResponse = \case
CodeUnredeemed -> Nothing
CodeRedeemedUnreadable -> Just $ errorResponse BSEInternal
CodeRedeemed RedeemedCode {purchaseKey = k, credential}
| k == purchaseKey -> Just $ credentialResponse credential
| otherwise -> Just $ errorResponse BSECodeUsed
-- used before signing and again inside the write transaction; every Left is a finished
-- response, an unknown code included
readCode code db = liftIO $
getBadgeCode db (badgeCodeHash code) >>= \case
Nothing -> pure $ Left $ errorResponse BSECodeInvalid
Just c@IssuedCode {redemption} -> fmap (const c) <$> checkUnspent db redemption
checkUnspent db = \case
CodeUnredeemed -> pure $ Right ()
CodeRedeemedUnreadable -> pure $ Left $ errorResponse BSEInternal
CodeRedeemed RedeemedCode {purchaseKey = k, badgePurchaseId, credential}
| k /= purchaseKey -> pure $ Left $ errorResponse BSECodeUsed
-- the whole ledger, so a client that lost the first response still ends holding it
| otherwise ->
maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing)
<$> getLedgerEntries db badgePurchaseId 0
-- TODO [badges] the statement is empty until the ledger is written
credentialResponse :: BadgeCredential -> BadgeServiceResponse
credentialResponse credential =
BSPBadgeCredential {credential = Just credential, receipt = Nothing, statement = BadgeStatement {entries = [], previousEntryId = Nothing}}
addMonths :: Integer -> UTCTime -> UTCTime
addMonths n (UTCTime d t) = UTCTime (addGregorianMonthsClip n d) t
-- Every badge in a week expires together, revealing nothing about when it was bought.
-- The end of a Sunday is the next Monday at 00:00, so this returns a Monday and 8 is right.
endOfSundayAfter :: UTCTime -> UTCTime
endOfSundayAfter (UTCTime d _) =
let (_, _, dayOfWeek) = toWeekDate d -- 1 Monday .. 7 Sunday
in UTCTime (addDays (toInteger (8 - dayOfWeek)) d) 0
-- | The purchase is reached through the verified signer key and no other way.
issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO BadgeServiceResponse
issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do
now <- badgeNow cc
purchase_ <- withDB' "getBadgePurchase" cc $ \db -> do
p_ <- getPurchaseByKey db purchaseKey
forM p_ $ \p@ServicePurchase {badgePurchaseId} -> (p,) <$> getLedgerTip db badgePurchaseId
case purchase_ of
Left _ -> pure $ errorResponse BSEInternal
Right Nothing -> pure $ errorResponse BSEUnknownPurchaseKey
Right (Just (ServicePurchase {badgePurchaseId, masterKey, badgeType}, tip)) -> do
(lapseUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc
let tipEntry = fromMaybe (emptyEntry now badgeType) tip
lapsed = lapseEntry now lapseUuid tipEntry
current = fromMaybe tipEntry lapsed
case issueEntry now issueUuid current of
Nothing -> writeIssued badgePurchaseId tip (maybeToList lapsed) now Nothing
Just e ->
credentialForEntry key masterKey e >>= \case
Left err -> logError ("badge service signing failed: " <> T.pack err) $> errorResponse BSEInternal
Right signed ->
writeIssued badgePurchaseId tip (maybeToList lapsed) now $ Just $ issuanceAfter current signed
where
-- the rows were computed from a tip that another request may have moved, and an issuance was
-- signed against it - so write only if it is still the tip
writeIssued purchaseId tip rows t issuance_ = do
r <- withDB "issueBadge" cc $ \db -> liftIO $ do
tip' <- getLedgerTip db purchaseId
when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId rows issuance_
issueResponse db purchaseId t
pure $ fromRight (errorResponse BSEInternal) r
-- entries after the one asserted, or the whole ledger when this purchase does not hold it.
-- Only the asserted entry's identity is read, never the months it claims.
issueResponse db purchaseId t = do
let StatementEntry {entryId = assertedUuid} = lastEntry
assertedId <- getLedgerEntryId db purchaseId assertedUuid
-- TODO [badges] when the assertion does not resolve, heal the ledger and restate it as a
-- single opening credit (badges-rpc.md), rather than resending the whole history
entries_ <- getLedgerEntries db purchaseId (fromMaybe 0 assertedId)
credential_ <- getCurrentIssuance db purchaseId t
pure $ maybe (errorResponse BSEInternal) (credentialResponse credential_ (assertedUuid <$ assertedId)) entries_
@@ -9,10 +9,17 @@ module BadgeService.Store
( IssuedCode (..),
CodeRedemption (..),
RedeemedCode (..),
NewBadgeCodeRedemption (..),
NewCodePurchase (..),
ServicePurchase (..),
getBadgeCode,
purchaseKeyExists,
writeCodeRedemption,
getPurchaseByKey,
getLedgerTip,
getLedgerEntryId,
getLedgerEntries,
getCurrentIssuance,
appendLedgerPlan,
createCodePurchase,
insertBadgeCode,
)
where
@@ -24,6 +31,8 @@ import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType)
import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service (StatementEntry (..))
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Messaging.Agent.Store.DB (Binary (..))
@@ -32,16 +41,17 @@ import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow')
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
import Database.PostgreSQL.Simple (Only (..), (:.) (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..))
import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql)
#endif
data IssuedCode = IssuedCode
{ badgeCodeId :: Int64,
badgeType :: BadgeType,
months :: Int,
redemption :: CodeRedemption
}
@@ -53,23 +63,24 @@ data CodeRedemption
| CodeRedeemedUnreadable
data RedeemedCode = RedeemedCode
{ purchaseKey :: C.PublicKeyEd25519,
{ badgePurchaseId :: Int64,
purchaseKey :: C.PublicKeyEd25519,
credential :: BadgeCredential
}
-- Everything writeCodeRedemption inserts, so that the purchase, its issuance and the spent code
-- are written in one transaction. If the code were marked redeemed and one of the other writes
-- failed, it would be spent with no credential behind it, and nothing can reissue it.
data NewBadgeCodeRedemption = NewBadgeCodeRedemption
-- Its rows and issuance are appended by 'appendLedgerPlan' in the same transaction: a code marked
-- redeemed while another write failed would be spent with no credential, and nothing reissues it.
data NewCodePurchase = NewCodePurchase
{ badgeCodeId :: Int64,
issuanceId :: Text,
purchaseKey :: C.PublicKeyEd25519,
masterKey :: BadgeMasterKey,
badgeType :: BadgeType,
credential :: BadgeCredential,
periodStart :: UTCTime,
periodEnd :: UTCTime,
expiry :: UTCTime
badgeType :: BadgeType
}
data ServicePurchase = ServicePurchase
{ badgePurchaseId :: Int64,
masterKey :: BadgeMasterKey,
badgeType :: BadgeType
}
getBadgeCode :: DB.Connection -> ByteString -> IO (Maybe IssuedCode)
@@ -78,23 +89,23 @@ getBadgeCode db codeHash =
DB.query
db
[sql|
SELECT c.badge_code_id, c.badge_type, p.purchase_key, i.credential
SELECT c.badge_code_id, c.badge_type, c.months, p.badge_purchase_id, p.purchase_key, i.credential
FROM sx_badge_service_badge_codes c
LEFT JOIN sx_badge_service_badge_purchases p ON p.badge_code_id = c.badge_code_id
LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id
WHERE c.code_hash = ?
ORDER BY i.created_at DESC
ORDER BY i.period_end DESC
LIMIT 1
|]
(Only (Binary codeHash))
where
toCode (badgeCodeId, badgeType, purchaseKey_, credential_) =
IssuedCode {badgeCodeId, badgeType, redemption = codeRedemption purchaseKey_ credential_}
codeRedemption purchaseKey_ credential_ = case purchaseKey_ of
Nothing -> CodeUnredeemed
Just purchaseKey -> case decodeCredential =<< credential_ of
Just credential -> CodeRedeemed RedeemedCode {purchaseKey, credential}
toCode (badgeCodeId, badgeType, months, purchaseId_, purchaseKey_, credential_) =
IssuedCode {badgeCodeId, badgeType, months, redemption = codeRedemption purchaseId_ purchaseKey_ credential_}
codeRedemption purchaseId_ purchaseKey_ credential_ = case (purchaseId_, purchaseKey_) of
(Just badgePurchaseId, Just purchaseKey) -> case decodeCredential =<< credential_ of
Just credential -> CodeRedeemed RedeemedCode {badgePurchaseId, purchaseKey, credential}
Nothing -> CodeRedeemedUnreadable
_ -> CodeUnredeemed
decodeCredential (Binary bs) = J.decodeStrict' bs
purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool
@@ -102,9 +113,126 @@ purchaseKeyExists db key =
maybeFirstRow' False (\(Only (_ :: Int64)) -> True) $
DB.query db "SELECT badge_purchase_id FROM sx_badge_service_badge_purchases WHERE purchase_key = ?" (Only key)
-- one transaction: the caller has already signed, so no code is left spent without a credential
writeCodeRedemption :: DB.Connection -> NewBadgeCodeRedemption -> UTCTime -> IO ()
writeCodeRedemption db NewBadgeCodeRedemption {badgeCodeId, issuanceId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType, credential, periodStart, periodEnd, expiry} now = do
-- | The only route from a command to a purchase, so a client cannot name one it cannot sign for.
getPurchaseByKey :: DB.Connection -> C.PublicKeyEd25519 -> IO (Maybe ServicePurchase)
getPurchaseByKey db key =
maybeFirstRow toPurchase $
DB.query
db
[sql|
SELECT badge_purchase_id, master_key, current_badge_type
FROM sx_badge_service_badge_purchases
WHERE purchase_key = ?
|]
(Only key)
where
toPurchase (badgePurchaseId, Binary mk, badgeType) =
ServicePurchase {badgePurchaseId, masterKey = BadgeMasterKey mk, badgeType}
getLedgerTip :: DB.Connection -> Int64 -> IO (Maybe StatementEntry)
getLedgerTip db purchaseId =
maybeFirstRow' Nothing toEntry $
DB.query
db
[sql|
SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
entry_type, entry_credit_type, entry_debit_type, service_created_at
FROM sx_badge_service_badge_ledger
WHERE badge_purchase_id = ?
ORDER BY entry_id DESC
LIMIT 1
|]
(Only purchaseId)
-- | The uuid is the client's claim about its last held entry, so the lookup is scoped to its own
-- purchase - an entry_id taken from another ledger would silently skip rows of this one.
getLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64)
getLedgerEntryId db purchaseId entryUuid =
maybeFirstRow fromOnly $
DB.query
db
"SELECT entry_id FROM sx_badge_service_badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?"
(purchaseId, entryUuid)
-- | 0 for the whole ledger, as entry_id starts at 1. 'Nothing' when a stored row has a type this
-- version cannot represent, rather than sending it changed into another.
getLedgerEntries :: DB.Connection -> Int64 -> Int64 -> IO (Maybe [StatementEntry])
getLedgerEntries db purchaseId afterEntryId =
mapM toEntry
<$> DB.query
db
[sql|
SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
entry_type, entry_credit_type, entry_debit_type, service_created_at
FROM sx_badge_service_badge_ledger
WHERE badge_purchase_id = ? AND entry_id > ?
ORDER BY entry_id
|]
(purchaseId, afterEntryId)
toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) -> Maybe StatementEntry
toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) =
(\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType})
<$> entryTypeFromColumns entryType_ credit_ debit_
-- | Answers a repeat inside an issued month, rather than signing the same content twice.
getCurrentIssuance :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe BadgeCredential)
getCurrentIssuance db purchaseId now = do
rs <-
DB.query
db
[sql|
SELECT credential FROM sx_badge_service_badge_issuances
WHERE badge_purchase_id = ? AND period_end > ?
ORDER BY period_end DESC
LIMIT 1
|]
(purchaseId, now)
pure $ case rs of
[Only (Binary bs)] -> J.decodeStrict' bs
_ -> Nothing
-- | The issuance is the entry that spends the month and the one before it, which give the period.
-- TODO [badges] also write the reference columns - payment_id, charge_id, from_purchase_id,
-- to_purchase_id - for the entry types that carry one. Only the tag is written today, so a
-- payment, charge, transferIn, upgrade or transferOut row would be stored without its reference.
appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO ()
appendLedgerPlan db purchaseId rows issuance_ = do
mapM_ appendRow rows
case issuance_ of
Nothing -> pure ()
Just (previous, issued@StatementEntry {entryId, balanceStartTs = periodEnd, balanceBadgeType, createdAt}, credential) -> do
rowId <- appendRow issued
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_issuances
(issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?,?)
|]
-- the issued entry's uuid is the issuance id: one issuance per such entry, and entry uuids
-- are already unique across the ledger, so nothing has to be drawn for it
( (entryId, purchaseId, rowId, balanceBadgeType)
:. (balanceStartTs previous, periodEnd, endOfMondayAfter periodEnd, Binary (LB.toStrict $ J.encode credential), createdAt)
)
where
appendRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, createdAt, entryType} = do
let (entryTypeT, creditType, debitType) = entryTypeColumns entryType
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_ledger
(entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts,
balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|]
((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType))
insertedRowId db
-- redeemed_at is stamped here, so this must share a transaction with the credential's rows:
-- a code marked spent without one can never be reissued
createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO Int64
createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do
DB.execute
db
[sql|
@@ -114,15 +242,8 @@ writeCodeRedemption db NewBadgeCodeRedemption {badgeCodeId, issuanceId, purchase
|]
(purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now)
purchaseId <- insertedRowId db
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_issuances
(issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(issuanceId, purchaseId, badgeType, periodStart, periodEnd, expiry, Binary (LB.toStrict $ J.encode credential), now)
DB.execute db "UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ?" (now, badgeCodeId)
pure purchaseId
insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO ()
insertBadgeCode db codeHash badgeType months paymentStatus now =
+2
View File
@@ -336,6 +336,7 @@ undocumentedCommands =
[ "APIAbortSwitchContact",
"APIAbortSwitchGroupMember",
"APIAcceptConditions",
"APIAckBadgeAlert",
"APIActivateChat",
"APIAddGroupShortLink",
"APIAddMyAddressShortLink",
@@ -371,6 +372,7 @@ undocumentedCommands =
"APIExportArchive",
"APIForwardChatItems",
"APIGetAppSettings",
"APIGetBadgeState",
"APIGetCallInvitations",
"APIGetChat",
"APIGetChatContentTypes",
+2
View File
@@ -172,6 +172,8 @@ undocumentedEvents =
"CEvtAgentConnsDeleted",
"CEvtAgentRcvQueuesDeleted",
"CEvtAgentUserDeleted",
"CEvtBadgeAlert",
"CEvtBadgeChanged",
"CEvtBusinessRequestAlreadyAccepted",
"CEvtCallAnswer",
"CEvtCallEnded",
+1
View File
@@ -130,6 +130,7 @@ undocumentedResponses =
"CRArchiveExported",
"CRArchiveImported",
"CRBadgeRedeemed",
"CRBadgeState",
"CRBroadcastSent",
"CRCallInvitations",
"CRChatCleared",
+4 -4
View File
@@ -27,7 +27,7 @@ A timeout hides the outcome, so the client repeats the identical signed request
## Commands
`purchaseBadge`, `upgradeBadgeSubscription`, and `issueBadge` carry `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.
`purchaseBadge` and `upgradeBadgeSubscription` carry `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.
- `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`.
@@ -35,7 +35,7 @@ A timeout hides the outcome, so the client repeats the identical signed request
- `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).
- `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).
## Upgrades
@@ -53,9 +53,9 @@ 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.
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.
`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`, and `balanceBadgeType`, `wasPausedSince` on the entry ending a pause, `createdAt`, and `entryType``credit`: `payment {invoiceId?}`, `charge {chargeId}`, `support`, `transferIn {fromPurchaseKey}`, `opening`; `debit`: `refund`, `upgrade {toPurchaseKey}`, `transferOut {toPurchaseKey}`, `support`, `badge`, `lapse`. 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. An unknown type is stored as received and decoded after an app upgrade.
`balance``lastEntry`, the client's last entry, asserting the position and the months it believes it holds.
+9 -2
View File
@@ -191,6 +191,10 @@
"changeMonths": {"type": "int16"},
"balanceMonths": {"type": "uint16"},
"balanceStartTs": {"type": "timestamp"},
"balanceAnchorTs": {
"type": "timestamp",
"metadata": {"comment": "start of the current run of months; boundaries are counted from it"}
},
"balanceBadgeType": {"type": "string"},
"createdAt": {"type": "timestamp"},
"entryType": {"ref": "ledgerEntryType"}
@@ -217,10 +221,14 @@
"optionalProperties": {
"invoiceId": {
"type": "string",
"metadata": {"comment": "absent for store and code payments"}
"metadata": {"comment": "absent for store payments"}
}
}
},
"code": {
"optionalProperties": {},
"metadata": {"comment": "a redeemed code; its invoice belongs to the buyer, not the redeemer"}
},
"charge": {
"properties": {"chargeId": {"type": "string"}}
},
@@ -323,7 +331,6 @@
},
"issueBadge": {
"properties": {
"badgeRequest": {"ref": "badgeRequest"},
"balance": {"ref": "balance"}
}
},
+11 -10
View File
@@ -308,8 +308,9 @@ Rules:
- `months` — unused months.
- `start` — the date the unused balance starts. Not changed by grants (while `months > 0`); advanced by one month per `consume` and by the lapsed count per `lapse`.
- `anchor` — the start of the current run of months, set only where a lapsed run restarts. Every month boundary of the run is counted from it, so a run beginning 31 Jan reaches 28 Feb and then returns to 31 Mar; counting from the moving `start` would hold it at the 28th for good.
Coverage = `[start, addMonths months start)`. `paidThrough = addMonths months start` — read from the last row alone; not a `badges` column.
Write `monthAfter n = addMonths (m + n) anchor`, where `m` is the whole months from `anchor` to `start`, which always sits on a boundary. Coverage = `[start, monthAfter months)`. `paidThrough = monthAfter months` — read from the last row alone; not a `badges` column.
**Row:**
@@ -319,6 +320,7 @@ Coverage = `[start, addMonths months start)`. `paidThrough = addMonths months st
- `delta` — signed months change
- `months` — state: unused months after this row
- `start` — state: balance start after this row
- `anchor` — state: the run's anchor after this row
- ref — bot-assigned payment ref / `charge_id` (grants)
- `created_at`
@@ -328,20 +330,19 @@ Append protocol: lock the badge's ledger → read the last row → compute the n
```
advance t: -- time bookkeeping only: one lapse row for the fully elapsed months
k = min months (fullMonthsBetween start t)
-- fullMonthsBetween start t: the largest m >= 0 with addMonths m start <= t
if k > 0: append (lapse, k, months k, addMonths k start) -- O11, one row
k = the largest k in [0, months] with monthAfter k <= t
if k > 0: append (lapse, k, months k, monthAfter k) -- O11, one row
issue t: -- run after advance t
requires months > 0 && start <= t && no issuance for [start, addMonths 1 start)
sign the credential, expiry sundayAfter (addMonths 1 start)
in one transaction: append (consume, 1, months 1, addMonths 1 start) -- O10
+ issuance row for [start, addMonths 1 start)
requires months > 0 && start <= t && no issuance for [start, monthAfter 1)
sign the credential, expiry sundayAfter (monthAfter 1)
in one transaction: append (consume, 1, months 1, monthAfter 1) -- O10
+ issuance row for [start, monthAfter 1)
on signing failure: no rows; retried at the next `issue`
grant t n src: -- O1O5; t = settlement time,
months == 0 → append (grant src, +n, n, max start t) -- provider period start for O2
months > 0 → append (grant src, +n, months + n, start)
months == 0 && t > start → append (grant src, +n, n, t), anchor = t -- provider period start for O2
otherwise → append (grant src, +n, months + n, start) -- same run keeps its anchor
debit reason: append (debit reason, months, 0, start) -- O6O9
+151
View File
@@ -0,0 +1,151 @@
# Badge renewal and alerts — code-redeemed badges
Continues `plans/2026-08-27-badges-mvp-streams.md` stream 1. Model `plans/2026-07-30-supporter-badges-v3-ux.md` §2.4, §2.9, §2.11, §3; engine sketch `plans/2026-07-31-badges-core-implementation.md` §6. Protocol `docs/protocol/badges-rpc.{md,schema.json}`.
Those two older plans predate the code. Where they disagree with what is built, what is built wins — see §7.
---
## 1. What this fixes
A code says how many months it is worth. Nothing reads it. `getBadgeCode` does not select `months`, and `redeemCode` hardcodes `addMonths 1 now`, so a twelve-month code buys one month and nothing renews it.
The ledger is the mechanism that fixes it: a redemption credits the months, each issuance debits one, and the client asks for the next one when it comes due.
**Scope.** Badges redeemed by code. No subscriptions, invoices, store payments, upgrades, transfers or pause — those tables and error paths stay untouched and unwritten. No app UI.
**Not in scope, deliberately.** Investor badges (the type stays, unused) and lifetime badges (being removed in `stable`, merged here later). Every badge in this slice has a balance and an expiry.
---
## 2. The ledger, minimally
Three operations, against the tables as built.
| op | entry_type | change | writes |
|---|---|---|---|
| grant, on redemption | `credit` / `payment` | +N months | one row |
| issue a month | `debit` / `badge` | 1 | one row + one `badge_issuances` row, same transaction |
| lapse elapsed months | `debit` / `lapse` | k | one row |
Every row carries the state after it: `balance_months`, `balance_start_ts`, `balance_badge_type`. **The last row is the state** — nothing derives a balance by summing, on either side.
- coverage is `[balance_start_ts, addMonths balance_months balance_start_ts)`
- `paidThrough` = `addMonths balance_months balance_start_ts`, read from the last row alone
- credential expiry = `endOfSundayAfter (addMonths 1 balance_start_ts)`**anchored on the balance, not on `now`**, or the schedule drifts a little further from the purchase every month
`advance t` runs before every grant and issue: lapse the fully elapsed unissued months in one row, if any. Issue requires `balance_months > 0`, `balance_start_ts <= t`, and no issuance already covering `[balance_start_ts, +1 month)` — that last check is what makes a repeated `issueBadge` return the stored credential and write nothing.
The columns this slice never writes stay NULL: `payment_id`, `charge_id`, `from_purchase_id`, `to_purchase_id`, `was_paused_since`. A code grant has no invoice, which the wire type already allows (`payment {invoiceId?}`, absent for codes) and the storage type does not — see §7.
`entry_uuid` is the service's identity for a row and the client's replication key. The client stores rows **verbatim** and never authors one; `entry_type_unknown` / `entry_type_value` keep a row a newer service wrote.
---
## 3. Service
- `getBadgeCode` selects `months` again; redemption grants that many.
- Redemption becomes: `advance` → grant → issue, and the response's `statement` carries the rows it wrote instead of being empty.
- `issueBadge` (`BSCIssueBadge`, already in the protocol) gets its handler: `advance`, then issue if a month is due, then reply. The `balance` the client asserts names its last held entry; the service returns the entries after it, or the whole ledger with an `opening` restatement when it names nothing the service holds.
- A repeat within an issued period replies with the **stored** credential and writes nothing. Not a re-sign: the same period signed twice yields a different signature and churns the client's credential for nothing.
- An exhausted balance is not an error: the reply carries no `credential` and a statement that shows why.
- Signing stays before every write, as in redemption. A signing failure writes nothing and the month is still due.
---
## 4. Client worker
One worker per user, on the agent `Worker` framework already used for delivery (`getAgentWorker` / `hasWorkToDo'` / `cancelWorker`, and the `TMap _ Worker` fields on `ChatController`). Per user because badge state is per profile and one profile must not stall another.
The worker holds no queue. A trigger only signals it; each pass reads stored state and derives the work. Signals are therefore free to be lost or duplicated.
**Pass**, per badge: is a month due, or has one lapsed, or is an alert derivable? If a request is needed, take the user's badge lock, send it signed with the purchase key, apply the response, then release. Applying a response means: copy the statement's entries verbatim, verify the credential, store the issuance, update the purchase, and re-present the badge to contacts.
**Triggers.** Chat start, chat activate, and redemption. Network restore and profile switch belong here too and cost nothing to add, but no caller sends them in this slice. Redemption has no immediate follow-up work — the round trip returns the credential and the statement, and the redeem command already stores the rows, sets the badge and broadcasts — but scheduling is something only a pass produces, so a purchase created without a signal has no wake-up until the next start or activate. Any later path that creates a purchase signals for the same reason.
**Scheduling.** No timer thread and no boundary map. Each pass ends by scheduling its own next wake-up, in the shape of `rescheduleWork` (`simplexmq` `NtfSubSupervisor.hs:478`): clear `doWork`, fork a sleeper that signals it at the next boundary, then block as usual. Two boundaries only, both day-granularity — the next month falling due, and `paidThrough`.
Unlike its original, this sleeper is **tracked per user and replaced**, following `deleteTimedItem` (`Internal.hs:1687`): cancel the previous before forking, cancel with the worker at chat stop, and re-check `waitChatStartedAndActivated` on waking. Badge horizons are a month where NtfSubSupervisor's are minutes, so untracked sleepers would accumulate — one per activate — instead of retiring.
**Expiry.** When the balance is exhausted and the last period ends, the shown badge is cleared and the profile update broadcast — the removal update of UX 2.11. This is the visible half of "the badge expired".
**Locking.** Add a `ChatLockEntity` constructor for the badge user and use `withEntityLock`, rather than the separate `badgeLocks` map §6 sketches. Same discipline, one lock map, and the `chatLock` ordering comes for free.
Nothing here is load-bearing for correctness: a pass derives its work from stored state, so chat start alone gives correct behaviour. It is what lets a client left running renew without a restart, and with `badgeGraceInterval` at 7 days a renewal hours late is invisible to contacts.
Timeouts are retried as the identical signed envelope at the next signal, never on a poll timer.
---
## 5. Alerts
One alert: `BASupportEnded`, at `paidThrough` with the balance exhausted, once.
`BAPrepaidEnding` — the 3-days-out warning — is **not** implemented, because a user cannot act on it. Topping up before the current period ends needs the service to credit a balance without issuing a credential, which is a change to the redemption path that beta does not carry; today a prepaid badge can only be continued after it has elapsed. Record that with a `TODO [badges]` in the alert derivation, where the missing branch is. The other three `BadgeAlertKind` constructors need subscriptions and stay unemitted.
Derived from state at the end of each pass, not stored as pending: compare the derived alert with `alert_acked_kind` / `alert_acked_episode` on the purchase, emit if they differ. The episode is the value that makes this occurrence distinct — `paidThrough`. Acknowledging writes the pair; snoozing sets `alert_snooze_until`, after which the alert is emitted once more. All three columns already exist.
---
## 6. Command and event surface
None of this exists — the names in §6 of the older plan have no code behind them. The minimum that makes the slice observable and testable from the terminal:
- `APIGetBadgeState` → the user's badges, balance, `paidThrough`, and current alert
- `APIAckBadgeAlert` — acknowledge or snooze
- `CEvtBadgeChanged` — state changed, including a renewal that arrived without a command
- `CEvtBadgeAlert` — an alert became derivable
Each needs its `chatCommandP` parser, `View.hs` rendering, and registration in `bots/src/API/Docs/{Commands,Responses}.hs`, which `tests/APIDocs.hs` enforces.
---
## 7. Types to correct first
Declared during design, never exercised — `Badges.Types`' `BadgePurchase`, `BadgeIssuance`, `CTPayment` and `CTCharge` have no users, so none of this costs a migration or a call site. (`Simplex.Chat.Badges` has a different `BadgePurchase`, the payment-proof sum, which `Badges.Types` hides; that one is in use and unaffected.)
- **Add a `code` credit type, on the wire and in storage.** The wire currently models a code grant as `payment` with `invoiceId` absent. Storing it as anything else would mean the client rewriting a row it is meant to replicate verbatim, so both sides gain the constructor together. It also makes the absence structural: a code's own invoice belongs to the buyer and lives in `badge_code_invoices` on the service, and the redeemer's ledger must never reference it. Cheap now, expensive once stream 2 ships.
- `CTPayment.invoiceId` becomes `InvoiceId`, not `Int64``invoices.invoice_id` is `TEXT`.
- `CTCharge.chargeId` becomes `Text`, matching `subscription_charges.charge_id` and its wire twin `SCCharge`, corrected in milestone A. Unused in this slice; changed so the twins stop disagreeing.
- `BadgePurchase` gains a funding sum — payment or code — rather than a mandatory `Int64` `paymentId`. Exactly one is set and the schema cannot say so, so the type should.
- `BadgeIssuance` loses its `Maybe` period, expiry and entry id. Lifetime is gone, the columns are `NOT NULL`, and every issuance is written beside exactly one `consume` row.
- `UserBadgeState`'s subscription fields stay: `renewsAt` is `Nothing` and `willRenew` is `False` until subscriptions exist.
Out of scope but worth knowing: `badge_ledger.payment_id` references a **payment** while the wire's `payment` credit names an **invoice**. Both are NULL for a code grant, so this slice never has to resolve it.
## 8. Order of work
**A — ledger on the service.** The injectable clock (§9), reading `months`, the three transitions, redemption grants and issues, the `issueBadge` handler, the statement in both responses. Done when a three-month code redeems and a second `issueBadge` a month later returns a second credential, asserted against the service's own rows.
**B — client replica.** Store the statement verbatim, read the balance from the last row, resolve §7's types. Done when the client's rows equal the service's row for row after a redemption.
**C — worker and renewal.** Worker, lock, self-scheduling, the pass, re-presentation. Done when a badge whose month has elapsed renews with no command, and one whose balance is exhausted loses its shown badge and broadcasts the removal.
**D — alerts and surface.** The ended alert, ack and snooze, the four commands and events. Done when the terminal shows it at `paidThrough` and acknowledging silences it.
## 9. Testing time
Nothing about the badge service is mocked: the harness already runs the real one in-process, so a fake would be less faithful and no faster. What is mocked is the clock.
**An injectable `now`**`IO UTCTime` in config, defaulting to `getCurrentTime`. No badge code calls the clock directly: the transitions already take `now` as a parameter, and so do their two callers, the service handler and the worker pass. In tests both sides read one source — real time plus a test-controlled offset — so a test can redeem a twelve-month code, jump the offset a month, signal the worker, and assert, twelve times over, in milliseconds and against the real service, real signing and real rows.
The offset tracks real time rather than freezing it, which is what keeps the sleeper honest: `rescheduleWork` computes `actionTs - now` in shifted time and still sleeps the right real duration.
This is preferred over making the issuance period configurable. The period is baked into the credential's signed expiry and interacts with the Sunday rounding, so shortening it to seconds means disabling the rounding too — two knobs, and production arithmetic that no test exercises. A clock offset leaves every production computation exactly as shipped and only lies about the date.
It also reaches what waiting cannot: eight months offline, a boundary on the 31st, a leap day. Those hold the bugs.
Real elapsed time is then needed for one thing only — that the sleeper wakes the worker at all. With the offset set a second short of a boundary that is a one-second test.
Tests land with each step, extending `tests/Bots/BadgeServiceTests.hs`.
## 10. Done means
- a twelve-month code yields twelve monthly credentials, one per month, and a thirteenth request yields none
- a second request inside an issued month returns the credential already stored, unchanged
- re-issue happens without a command, from the worker's own wake-up alone
- an app offline across several months lapses exactly the elapsed ones and issues the current one
- redeeming the same code twice still yields one badge and one set of ledger rows
- client and service ledgers match row for row, and the client authored none of them
- the ended alert fires once, survives a restart, and stays silent once acknowledged
- an expired badge disappears from contacts' view without the user acting
+103
View File
@@ -0,0 +1,103 @@
# Badge ledger: one entry type
This branch introduced seven types to describe ledger rows. Three hold nothing the wire's own `StatementEntry` does not already carry. Two describe a plan the caller can hold directly. One pairs a balance with a row id used for a single equality test. One holds three timestamps that two consecutive entries already determine. `StatementEntry` becomes the single representation, and nothing replaces the rest.
| introduced | what it holds beyond `StatementEntry` |
| --- | --- |
| `LedgerBalance` | nothing — its four fields are `StatementEntry`'s |
| `ServiceLedgerEntry` | nothing — `statementEntry` exists only to rename `entryUuid`, flatten the balance, and fill in a `wasPausedSince` the service never sets |
| `LedgerRow` | nothing — a change, a balance and a type, all fields of `StatementEntry` |
| `LedgerPlan` | the rows to write, and which is the issuance |
| `SignedPlan` | the same, with the credential |
| `LedgerTip` | a numeric row id beside a balance |
| `BadgePeriod` | three timestamps, all derivable from two consecutive entries |
## The entry is the state
The ledger's central property is that the last row *is* the balance. `StatementEntry` already carries it — `balanceMonths`, `balanceStartTs`, `balanceAnchorTs`, `balanceBadgeType` — alongside the change that produced it and the type of operation that made it. So an operation is a function from the last entry to the next. The three keep the shapes they have today, with `LedgerBalance` replaced by `StatementEntry`, a uuid added, and the credit type moving in from `ledgerPlan` because a grant now writes its own entry type:
```haskell
lapseEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry -- was advanceBalance
issueEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry -- was issueMonth
grantEntry :: UTCTime -> Text -> Int -> StatementCreditType -> StatementEntry -> StatementEntry -- was grantMonths
```
Each sets its own `changeMonths` and `entryType``SEDebit SDLapse`, `SEDebit SDBadge`, the caller's credit — so the count and the type it is recorded under travel together and cannot be mismatched. `Nothing` means the operation does not apply: nothing has elapsed, or no month is due. `grantEntry` is total, because a grant always applies.
The arithmetic inside them does not change. `entryId` and `createdAt` are supplied and never read, and `wasPausedSince` stays `Nothing` as the service sets it today. The price is that uuid generation moves from the store to the caller — which is also what lets an entry be identified before it is written, and be the value the credential is stored against.
The client is already here: `getBadgeLedgerLastEntry` returns a `StatementEntry`, because that is what the wire sends and what `badge_ledger` stores. This makes the service match rather than inventing a shape of its own.
## No plan, just composition
`ledgerPlan` goes too. A request lapses first, as its own entry, and the caller chains the rest, keeping whichever came back:
```haskell
let lapsed = lapseEntry now uuid1 tip
current = fromMaybe tip lapsed
issued = issueEntry now uuid2 current
rows = catMaybes [lapsed, issued]
```
A redemption is the same chain with a grant between the two: `lapseEntry`, `grantEntry`, `issueEntry`. It grants the months and issues the first of them at once, which is what `ledgerPlan` does today.
Do not skip the lapse before a grant. `grantEntry` restarts the run only when the balance is empty, so elapsed months still on the books get added to instead: three months bought on 10 January and another code redeemed on 10 June gives five months running from January, every one already spent, where lapsing first gives two running from June.
The caller generates one uuid per entry it might write. These two are the only callers, so a function to compose them would serve two sites that differ by one step.
This disposes of `LedgerPlan` and `SignedPlan` without replacing them: the rows are a list the caller already holds, and the credential belongs to `issued`, a local in scope rather than something to find by position or by type.
Two store functions simplify with them. `getLedgerEntries` returns `[StatementEntry]`, so `credentialResponse`'s `map statementEntry` disappears. And `appendLedgerPlan` loses both its `TVar ChaChaDRG` and its `now`, since the entries now carry their own ids and timestamps — it gains only the issuance's predecessor, which the caller has as `current`.
## The period is derived
`BadgePeriod` carried what an issuance writes to `badge_issuances`. Given the issuance entry and the one before it, all three values are already there:
- `periodStart` is the previous entry's `balanceStartTs`
- `periodEnd` is the issuance entry's own `balanceStartTs`, because issuing moves the start to the period end
- the expiry is a pure function of `periodEnd`
The client already derives the two bounds this way in `getIssuedPeriod`, and takes the expiry from the credential, which is the service's own computation arriving back. On the service, compute the expiry at both points it is needed — signing, then writing the issuance row — from the same entry through the same function.
The predecessor is not always one of the written rows — when nothing lapsed, the entry before the issue is the tip. Composing at the call site names it anyway: it is `current`, the value the issue was computed from, so the writer has it without having to look for it.
## The tip is just an entry
`LedgerTip` is the newest row's id and balance. The id has one use — checking that no row was written while the plan was being signed — and that only asks whether it is still the same row, which `StatementEntry`'s uuid answers. So `getLedgerTip` returns a `StatementEntry`.
Reading a row as an entry means decoding its type, which `entryTypeFromColumns` does only partially — but totally over `code`, `badge` and `lapse`, which is everything the service writes. So `Nothing` still means no rows.
## What stays shared
The client authors no entries — it stores what the service sends and reads the last one back. So `lapseEntry`, `issueEntry` and `grantEntry` are the service's alone after this change, though `Ledger.hs` stays one module and the client simply imports less of it.
The rest of the module changes only in its argument: `paidThrough`, `elapsedMonths`, `monthsFromAnchor` and `monthAfter` take a `StatementEntry` where they took a `LedgerBalance`. `addMonths`, the tag functions and the column helpers are untouched; the week-boundary function is shifted a day and renamed by the worker plan, not here. `elapsedMonths` need no longer be exported, since `lapseEntry` is the only caller. The client keeps `paidThrough` and gains the check below.
The client's remaining use, `ledgerPlan` inside `badgeWorkDue`, is removed by the worker plan.
## Verifying what the service sends
The client authors nothing, so it takes the service's arithmetic on trust — while holding everything needed to check it.
**Where.** In `applyBadgeStatement`, as the rows are stored. That is the one place holding both the arriving entries and the stored tip they follow, and it already runs in one transaction.
**What.** One outcome per entry, from one of two rules:
- *Against its predecessor* — the previous entry in the statement, or the stored tip for the first one, whatever `previousEntryId` claims. `balanceMonths` equals the predecessor's plus `changeMonths`; a debit moves `balanceStartTs` forward by exactly the months it consumed, counted from the anchor; a credit either leaves the start alone or restarts the run with `balanceStartTs` and `balanceAnchorTs` equal.
- *Opening*, when there is no predecessor at all: a credit whose `balanceMonths` equals its `changeMonths`, with start and anchor equal.
Checking the first entry against what the client actually holds is also what catches a statement that follows some other ledger — it fails its arithmetic — so `previousEntryId` needs no separate outcome.
**What happens when it fails: store the row and mark it.** Not refuse. Perks do not depend on the ledger — the credential is signed independently and a receiver verifies that signature — so rejecting a statement would strand a badge the service considers paid while proving nothing. The ledger is the user's record of what was spent, and the useful response to arithmetic that does not add up is to keep it and be able to point at the line.
**The column.** `balance_checked`, per entry — `1` when the entry follows from its predecessor, `0` when it does not, and null when nobody has looked. Nullable, because "can be checked" and "has been checked" are different things: every row has a predecessor to check against, and none has been checked while the check is a stub. Not `verified`, which already means signature verification on profiles and would read as the same thing.
The check belongs in `Ledger.hs`, beside the arithmetic it verifies — `monthsFromAnchor` is internal there and would otherwise have to be exported to check a debit's start. Stub it to null until it exists; adding the column now is what keeps it out of a migration of its own.
## The tests move with the types
The eight ledger-transition tests in `BadgeTests.hs` and their helpers move to `StatementEntry`. Assertions on `balanceMonths`, `balanceStartTs` and `paidThrough` survive as they are. The ones on periods do not: `pass` no longer returns a `BadgePeriod`, so `testTwelveMonths` and `testLapseAfterGap` state their period bounds as consecutive `balanceStartTs` values instead.
## Not in scope
The wire format does not change: `StatementEntry` is what the service already sends and the client already stores, and every column `badge_ledger` has today is still written. The only schema change is `balance_checked`, on the client's `badge_ledger` alone — the service has nothing to check, since it is the side that computes.
@@ -0,0 +1,168 @@
# Badge worker: one thread, one sleep
Today each user has two threads: a worker, and a sleeper that outlives each pass and signals it. That is where the `Weak ThreadId`, the swap-and-kill in `scheduleBadgeWake`, and the sleeper half of `stopBadgeWorkers` come from. The next wake is the earliest of four separately-derived times — a retry after a failed request, the snooze expiry, the stall floor, and the ledger's next renewal — and behind the first sit a per-purchase `(elapsed, delay)` map and three functions to advance it.
This assumes one badge per user. Most of what collapses here collapses because the maps keyed by purchase have a single key, and because `updateUserBadges` and `updateBadgePurchase` become one function, `updateUserBadge`.
```haskell
data BadgeWorker = BadgeWorker
{ badgeWorkerAsync :: Async (),
badgeWork :: TMVar ()
}
badgeWorkers :: TMap UserId (SessionVar BadgeWorker)
badgeSeq :: TVar Int
```
The loop, which the rest of this fills in:
```haskell
runBadgeWorker :: User -> TMVar () -> CM ()
runBadgeWorker user badgeWork = do
emitted <- newTVarIO Nothing
ri <- asks $ badgeRetryInterval . config
forever $ do
at_ <- withRetryInterval ri $ \_ loop -> do
now <- badgeNow
let done = pure $ Just $ badgeStalledInterval `addUTCTime` now
updateUserBadge user emitted now `catchAllErrors` retryBadgeError loop done
now <- badgeNow
liftIO $ waitBadgeWake badgeWork now at_
```
The clock is read twice because a retry sequence can run for hours, so the wait needs a fresh reading rather than the one the pass started from.
## Waiting
`registerDelay` makes the deadline an STM value, so the wait is a single transaction over the timer and the signal — no second thread, and the transaction reports which of the two woke it:
```haskell
waitBadgeWake :: TMVar () -> UTCTime -> Maybe UTCTime -> IO ()
waitBadgeWake badgeWork now = \case
Nothing -> atomically $ takeTMVar badgeWork
Just at -> waitFor $ diffToMicroseconds $ diffUTCTime at now
where
waitFor time
| time <= 0 = pure ()
| otherwise = do
let maxWait = min time $ fromIntegral (maxBound :: Int)
timer <- registerDelay $ fromIntegral maxWait
signalled <- atomically $ do
w <- tryTakeTMVar badgeWork
fired <- readTVar timer
unless (isJust w || fired) retry
pure $ isJust w
unless signalled $ waitFor $ time - maxWait
```
This is `threadDelay'` with an escape hatch, and it keeps that function's names so it reads as one. Nothing survives the wait, so there is no handle to keep and nothing to kill. `Nothing` means sleep until signalled.
`registerDelay` takes an `Int`, so the loop is needed for the same reason `threadDelay'` has one: `maxBound :: Int` is ~292,000 years on 64-bit and ~36 minutes on 32-bit. A month is one sleep everywhere except legacy armv7a, which re-arms — as it already does today. Counting down the remaining time rather than re-reading the clock keeps this correct when a test has shifted `badgeCurrentTime`.
Taking the signal and reading the timer in one transaction means a signal is never consumed and discarded.
## Retrying
`withRetryInterval` holds the backoff in its own recursion — no map, no `TVar`, nothing persisted:
```haskell
retryBadgeError :: CM a -> CM a -> ChatError -> CM a
retryBadgeError loop done e = eToView e >> if badgeErrorRetry e then loop else done
```
Same shape as `retryOnError` (`FileTransfer/Agent.hs:260`), except that it classifies with `badgeErrorRetry` rather than `temporaryOrHostError`, which does not cover `AGENT (A_SERVICE ASETimeout)` — the likeliest renewal failure, and using it would leave the retry inert for the common case.
`updateUserBadge` returns the next wake, as `updateBadgePurchase` does today. Retry-or-stop is a property of the error rather than a return value, so no result type is needed and `BadgeRetry` collapses to `badgeErrorRetry :: ChatError -> Bool`.
A service error is the exception, since it is returned rather than thrown: `requestBadgeIssue` becomes `Either (Maybe UTCTime) LedgerBalance`, where `Left` means do not retry and wake then. That is where a `retryAfter` hint lands — `withRetryInterval` owns the sleeping, so a wait the service names becomes a wake time — and where a terminal code lands as the stall floor. Floor the hint at `initialInterval`, since a service answering `0` would otherwise spin the worker, but do not cap it: a service that names a long wait is denying issuance, which it can already do by refusing, and `paidThrough` remains a wake candidate regardless of what it says.
**The stall floor is a day.** A terminal failure is not retried, and the ledger boundary is a whole term away. The client's own state cannot change without the service, so the only thing that can make the next attempt succeed is the service being repaired — a day is slow enough not to press a service already failing, and fast enough to recover well inside the badge's 8-14 days of headroom.
**The loop cannot die.** Catching everything except cancellation means no exception ends it, and a failure that reaches the top returns the stall floor as its wake, so a persistent fault is one attempt a day rather than a hot loop. That replaces the `Worker`'s rate-limited restart.
## When renewal is due
Renewal is driven by the **credential's expiry**, not the ledger's period end. A badge whose period has ended but whose credential is still valid needs nothing done — the holder keeps their perks, and the service is not asked early.
The expiry rounding moves one day later, from the Monday after the period to the Tuesday: `endOfSundayAfter`'s `8 - dayOfWeek` becomes `9 - dayOfWeek`. Periods ending anywhere in one Monday-to-Sunday week still share a single expiry, so the anonymity set is unchanged; it now falls on a weekday in every timezone, where Monday 00:00 UTC is Sunday evening in the Americas and puts a failed renewal on a weekend for support. The function is renamed for the day it now returns, and the range in `testSundayExpiry` widens from 1-7 days to 2-8.
Renewal splits into two steps, normally a day apart:
- **Request**, on the Monday — while the held credential is still valid, so a failure has a day of slack before anything is visible.
- **Present**, on the Tuesday — as the old credential lapses, so the profile broadcast does not correlate with the request that produced it.
Neither needs a marker. Both derive from the shown credential's expiry, which the profile already stores, and from whether a newer issuance exists, which `presentIssuedBadge` already compares:
- **Request** when the shown credential expires within a day, months remain, and the newest issuance is still the one shown — so nothing has been requested yet. A successful request moves the newest expiry a month out, and the condition stops holding by itself.
- **Present** when the newest issuance differs from what is shown and the shown credential has expired — or when nothing is shown at all, which is the state a crash between the issuance write and the profile write leaves behind, and which `testPresentationCatchesUp` covers.
Ordering falls out of that: presenting cannot precede requesting, because nothing differs until the request succeeds.
`badgeBoundary` becomes the next of three moments: `shownExpiry - 1 day`, `shownExpiry`, and `paidThrough`. The ledger's `balanceStartTs` goes, since renewal is no longer month-aligned — but `paidThrough` stays, and for a different reason from the other two. The credential's expiry window is what covers renewal: the client renews around it to join the anonymity set, and the recipients' grace period keeps the badge honoured while that happens. `paidThrough` is when entitlement itself ends. The worker has to be there for it, to retire the badge and raise the alert that tells the user to buy again; waiting for the credential to expire would leave them wearing a badge they have stopped paying for.
Missing a week is safe. A worker whose first run is the Wednesday finds both conditions true and does both in one pass: that renewal loses its anonymity benefit, and nothing else changes, which is the same property every other wake here has.
## The pass
```haskell
updateUserBadge :: User -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime)
updateUserBadge user emitted now = do
(p, balance) <- ...
retired <- retireExpiredBadge user p now balance
balance' <-
if retired
then pure balance
else do
b <- if requestDue p balance now then requestBadgeIssue ... else pure balance
when (presentDue p now) $ presentIssuedBadge user p now
pure b
emitBadgeAlert user emitted p now balance'
pure $ earliestTime [badgeBoundary now p balance', snoozeAt p now]
```
Retiring ends the renewal half of the pass. It means `paidThrough <= now`, so every funded month has already passed and a request could only write lapse rows — it cannot issue, because `advanceBalance` consumes the balance before `issueMonth` sees it. Reconciling the ledger for a badge that is over is not worth a round trip; a later redemption reconciles it anyway by asserting the last entry.
The guard is on `retired` rather than on the purchase's `shown` field because `p` was read before retirement and its `shown` is stale within the pass. `presentIssuedBadge` has its own `not shown` check, which covers later passes but not this one.
The alert stays outside the guard: support-ended fires exactly when the balance is exhausted, which is the pass that retires.
Retirement comes first because it needs no service and reads only stored state, and because an unbounded retry does not return while a failure lasts — anything after the request is unreachable meanwhile. Moving it ahead of the request is behaviour-preserving: `paidThrough` is invariant under issue and lapse, only a grant moves it, and `BSCIssueBadge` never produces one, so it gives the same answer either side.
## Starting and stopping
Chat start, `/_app activate`, `APIGetBadgeState` and a redemption can each ask for the worker at the same moment. Exactly one thread must be started, and every caller must come away holding it.
`getAgentWorker'` manages that by doing the lookup and the create in a single STM transaction, which works only because creating a `Worker` allocates a few TVars and nothing else. Starting a thread is IO and cannot happen inside a transaction, so for us the lookup and the create come apart, and two callers can both find nothing.
`SessionVar` closes the gap by putting a `TMVar` in the map instead of the value — the map holds the promise of a worker rather than a worker. `getSessVar` either inserts an empty one and returns `Left`, meaning you are the creator, or finds an existing one and returns `Right`. Exactly one caller gets `Left`.
```haskell
withGetSessVar' badgeSeq userId badgeWorkers now startWorker signalExisting
where
startWorker v = do
badgeWork <- newTMVarIO () -- full: a new worker has work to do
a <- async $ runBadgeWorker user badgeWork
let w = BadgeWorker {badgeWorkerAsync = a, badgeWork}
w <$ atomically (putTMVar (sessionVar v) w)
signalExisting v = do
w <- atomically $ readTMVar $ sessionVar v
w <$ atomically (void $ tryPutTMVar (badgeWork w) ())
```
`readTMVar` blocks the other callers until the creator fills the var, so they signal the one worker rather than starting a second. The hazard is the creator dying between those two steps, leaving an empty var everyone waits on forever; `withGetSessVar'` wraps the creating branch in `bracketOnError` and drops it from the map so the next caller creates a fresh one.
Shutdown follows `closeAgentClient`, which stops a `TMap k (SessionVar (Async ()))` the same way: swap the map out, then for each var fork a thread that waits on `readTMVar` and `uninterruptibleCancel`s what it finds. Waiting rather than skipping is what catches a worker created after the swap; the fork is so shutdown does not block on it.
## What goes away
| removed | replaced by |
| --- | --- |
| `Worker`, `getAgentWorker'`, `cancelWorker`, restart accounting | `Async`, `badgeWork`, `SessionVar` |
| `scheduleBadgeWake`, `Weak ThreadId`, `killWeakThread`, sleeper cleanup | `waitBadgeWake` |
| `BadgeAttempt`, `nextBadgeAttempt`, `badgeAttemptDelay` | `withRetryInterval`'s own recursion |
| `BadgeMemory` and its two maps | one `TVar (Maybe BadgeOccurrence)`, once a user has one badge |
| `BadgeRetry`'s two constructors | `badgeErrorRetry :: ChatError -> Bool` |
Wake candidates go from four to three. The one that goes is the retry, because `withRetryInterval` sleeps between attempts rather than returning a time; the next request or present day, the snooze expiry, and the stall floor — or a wait the service named — all remain.
Unchanged: everything derived from stored state so a wake early, late or missed changes only timing; the alert-occurrence memory; `badgeCurrentTime`; `RetryInterval` in config, both as the existing pattern and so a test can shorten it. Signalling `badgeWork` replaces `startBadgeWork` at the same call sites.
+2
View File
@@ -41,6 +41,7 @@ library
Simplex.Chat.Badges
Simplex.Chat.Badges.CLI
Simplex.Chat.Badges.Code
Simplex.Chat.Badges.Ledger
Simplex.Chat.Badges.Service
Simplex.Chat.Badges.Types
Simplex.Chat.Names
@@ -431,6 +432,7 @@ executable simplex-badge-service
aeson ==2.2.*
, attoparsec ==0.14.*
, base >=4.7 && <5
, containers ==0.6.*
, crypton ==0.34.*
, directory ==1.3.*
, mtl >=2.3.1 && <3.0
+6
View File
@@ -69,6 +69,8 @@ defaultChatConfig =
chatVRange = supportedChatVRange,
badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys,
badgeServiceAddress = Nothing,
badgeCurrentTime = getCurrentTime,
badgeRetryInterval = RetryInterval {initialInterval = 30_000000, increaseAfter = 0, maxInterval = 3600_000000},
confirmMigrations = MCConsole,
-- this property should NOT use operator = Nothing
-- non-operator servers can be passed via options
@@ -189,6 +191,8 @@ newChatController
deliveryTaskWorkers <- TM.emptyIO
deliveryJobWorkers <- TM.emptyIO
relayRequestWorkers <- TM.emptyIO
badgeWorkers <- TM.emptyIO
badgeSeq <- newTVarIO 0
relayGroupLinkChecksAsync <- newTVarIO Nothing
webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState
chatRelayTests <- TM.emptyIO
@@ -235,6 +239,8 @@ newChatController
deliveryTaskWorkers,
deliveryJobWorkers,
relayRequestWorkers,
badgeWorkers,
badgeSeq,
relayGroupLinkChecksAsync,
webPreviewState,
chatRelayTests,
+2 -2
View File
@@ -3,11 +3,11 @@
-- | Badge redemption codes, shared by the client, the badge service and the checkout site.
--
-- A code is @SXB-@ and 20 Crockford base32 characters in four groups of five:
-- A code is "SXB-" and 20 Crockford base32 characters in four groups of five:
-- 19 payload characters and a final check character.
--
-- Reading folds the characters the alphabet omits so that a code copied by hand still
-- verifies: it is case-insensitive and maps @I@ and @L@ to @1@ and @O@ to @0@.
-- verifies: it is case-insensitive and maps 'I' and 'L' to '1' and 'O' to '0'.
--
-- The check character is Luhn mod N with N = 32 over the payload values, which keeps it
-- inside the same 32-character alphabet. It detects every single-character substitution
+176
View File
@@ -0,0 +1,176 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Badges.Ledger
( emptyEntry,
lapseEntry,
grantEntry,
issueEntry,
paidThrough,
balanceChecked,
addMonths,
endOfMondayAfter,
entryTypeColumns,
entryTypeFromColumns,
creditTypeTag,
debitTypeTag,
)
where
import Data.Text (Text)
import Data.Time.Calendar (addDays, addGregorianMonthsClip, toGregorian)
import Data.Time.Calendar.WeekDate (toWeekDate)
import Data.Time.Clock (UTCTime (..))
import Simplex.Chat.Badges (BadgeType)
import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..))
-- | balanceStartTs is always a whole number of months from the anchor; this is that number.
-- The calendar difference overshoots by at most one month, so one comparison settles it.
monthsFromAnchor :: StatementEntry -> Integer
monthsFromAnchor StatementEntry {balanceStartTs, balanceAnchorTs}
| addMonths months balanceAnchorTs <= balanceStartTs = max 0 months
| otherwise = max 0 (months - 1)
where
(ay, am, _) = toGregorian (utctDay balanceAnchorTs)
(sy, sm, _) = toGregorian (utctDay balanceStartTs)
months = (sy - ay) * 12 + toInteger (sm - am)
-- | The start of the month that follows n more months of this run.
monthAfter :: StatementEntry -> Int -> UTCTime
monthAfter e n = addMonths (monthsFromAnchor e + toInteger n) (balanceAnchorTs e)
paidThrough :: StatementEntry -> UTCTime
paidThrough e = monthAfter e (balanceMonths e)
elapsedMonths :: UTCTime -> StatementEntry -> Int
elapsedMonths t e = length $ takeWhile (\m -> monthAfter e m <= t) [1 .. balanceMonths e]
-- | The seed for a purchase with no ledger yet: no months, and a run starting now.
emptyEntry :: UTCTime -> BadgeType -> StatementEntry
emptyEntry t badgeType =
StatementEntry
{ -- this entry is never stored, and every operation puts its own id on the entry it returns
entryId = "",
changeMonths = 0,
balanceMonths = 0,
balanceStartTs = t,
balanceAnchorTs = t,
balanceBadgeType = badgeType,
wasPausedSince = Nothing,
createdAt = t,
entryType = SECredit SCOpening
}
-- | Writes off the months that have passed.
lapseEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry
lapseEntry t entryId e@StatementEntry {balanceMonths}
| k == 0 = Nothing
| otherwise =
Just
e
{ entryId,
createdAt = t,
changeMonths = negate k,
balanceMonths = balanceMonths - k,
balanceStartTs = monthAfter e k,
entryType = SEDebit SDLapse
}
where
k = elapsedMonths t e
-- | New months start where the current coverage ends, or at t if it has already lapsed - so they
-- are neither spent on the month still running nor backdated over a gap.
grantEntry :: UTCTime -> Text -> Int -> StatementCreditType -> StatementEntry -> StatementEntry
grantEntry t entryId n credit e@StatementEntry {balanceMonths, balanceStartTs}
-- only a lapsed run restarts; topping up before coverage ends continues the run on its anchor,
-- so buying a month at a time keeps the same day of month as buying a year at once
| lapsed = credited {balanceMonths = n, balanceStartTs = t, balanceAnchorTs = t}
| otherwise = credited {balanceMonths = balanceMonths + n}
where
lapsed = balanceMonths == 0 && t > balanceStartTs
credited = e {entryId, createdAt = t, changeMonths = n, entryType = SECredit credit}
-- | The period issued runs from the previous entry's balanceStartTs to this one's.
issueEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry
issueEntry t entryId e@StatementEntry {balanceMonths, balanceStartTs}
| balanceMonths <= 0 || balanceStartTs > t = Nothing
| otherwise =
Just
e
{ entryId,
createdAt = t,
changeMonths = -1,
balanceMonths = balanceMonths - 1,
balanceStartTs = monthAfter e 1,
entryType = SEDebit SDBadge
}
-- | Pairs each arriving entry with whether its balance follows from the one before it, the stored
-- tip standing in for the first one's predecessor. 'Nothing' is "not checked".
-- TODO [badges] do the arithmetic.
balanceChecked :: Maybe StatementEntry -> [StatementEntry] -> [(StatementEntry, Maybe Bool)]
balanceChecked _tip = map (\e -> (e, Nothing))
-- | The tag stored is the string the service sent, so a type this version does not know is kept
-- as received and can be read once it does.
entryTypeColumns :: StatementEntryType -> (Text, Maybe Text, Maybe Text)
entryTypeColumns = \case
SECredit c -> ("credit", Just $ creditTypeTag c, Nothing)
SEDebit d -> ("debit", Nothing, Just $ debitTypeTag d)
creditTypeTag :: StatementCreditType -> Text
creditTypeTag = \case
SCPayment _ -> "payment"
SCCode -> "code"
SCCharge _ -> "charge"
SCSupport -> "support"
SCTransferIn _ -> "transferIn"
SCOpening -> "opening"
SCUnknown {tag} -> tag
debitTypeTag :: StatementDebitType -> Text
debitTypeTag = \case
SDRefund -> "refund"
SDUpgrade _ -> "upgrade"
SDTransferOut _ -> "transferOut"
SDSupport -> "support"
SDBadge -> "badge"
SDLapse -> "lapse"
SDUnknown {tag} -> tag
-- | Only the types a tag alone rebuilds, which is those whose constructor has no fields; the rest
-- answer Nothing rather than a type with an invented payload. The client also stores each type's
-- JSON and reads that first, so this is its fallback; the service has no such column.
-- TODO [badges] take the reference columns and rebuild payment, charge, transferIn, upgrade and
-- transferOut, without which the service cannot re-emit a statement carrying one.
entryTypeFromColumns :: Text -> Maybe Text -> Maybe Text -> Maybe StatementEntryType
entryTypeFromColumns entryType credit_ debit_ = case (entryType, credit_, debit_) of
("credit", Just t, _) -> SECredit <$> creditType t
("debit", _, Just t) -> SEDebit <$> debitType t
_ -> Nothing
where
creditType = \case
"code" -> Just SCCode
"support" -> Just SCSupport
"opening" -> Just SCOpening
_ -> Nothing
debitType = \case
"badge" -> Just SDBadge
"lapse" -> Just SDLapse
"refund" -> Just SDRefund
"support" -> Just SDSupport
_ -> Nothing
addMonths :: Integer -> UTCTime -> UTCTime
addMonths n (UTCTime d t) = UTCTime (addGregorianMonthsClip n d) t
-- Every badge in a week expires together, revealing nothing about when it was bought.
-- The end of a Monday is the next Tuesday at 00:00, so this returns a Tuesday and 9 is right.
-- Returning a Monday instead would put the expiry on Sunday evening in the Americas, leaving a
-- renewal that failed there waiting for weekend support.
endOfMondayAfter :: UTCTime -> UTCTime
endOfMondayAfter (UTCTime d _) =
let (_, _, dayOfWeek) = toWeekDate d -- 1 Monday .. 7 Sunday
in UTCTime (addDays (toInteger (9 - dayOfWeek)) d) 0
+6 -3
View File
@@ -98,8 +98,7 @@ data BadgeServiceCommand
balance :: BadgeBalance
}
| BSCIssueBadge
{ badgeRequest :: BadgeRequest,
balance :: BadgeBalance
{ balance :: BadgeBalance -- no badgeRequest: the service holds the key, the tier and the expiry
}
| BSCPauseBadge
@@ -173,6 +172,9 @@ data StatementEntry = StatementEntry
changeMonths :: Int,
balanceMonths :: Int,
balanceStartTs :: UTCTime,
-- the start of the current run of months; every month boundary in it is counted from here,
-- so that the day of month survives a short month
balanceAnchorTs :: UTCTime,
balanceBadgeType :: BadgeType,
wasPausedSince :: Maybe UTCTime,
createdAt :: UTCTime,
@@ -184,7 +186,8 @@ data StatementEntryType = SECredit {credit :: StatementCreditType} | SEDebit {de
deriving (Show)
data StatementCreditType
= SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store and code payments
= SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store payments
| SCCode -- a redeemed code; its own invoice belongs to the buyer, not to the redeemer
| SCCharge {chargeId :: Text}
| SCSupport
| SCTransferIn {fromPurchaseKey :: C.PublicKeyEd25519}
+61 -18
View File
@@ -18,12 +18,13 @@ module Simplex.Chat.Badges.Types
LedgerCreditType (..),
LedgerDebitType (..),
BadgeAlertKind (..),
BadgeFunding (..),
BadgePurchase (..),
BadgeLedgerEntry (..),
BadgeCharge (..),
BadgeIssuance (..),
BadgeAlert (..),
UserBadgeState (..),
BadgeState (..),
) where
import Data.Aeson (FromJSON, ToJSON)
@@ -34,12 +35,12 @@ import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Word (Word8)
import Simplex.Chat.Badges hiding (BadgePurchase (..))
import Simplex.Chat.PaymentService.Types (InvoiceId, StoredPayment)
import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId, StoredPayment)
import Simplex.Messaging.Agent.Protocol (UserId)
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, taggedObjectJSON)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple.FromField (FromField (..))
import Database.PostgreSQL.Simple.ToField (ToField (..))
@@ -84,8 +85,9 @@ data LedgerEntryType = LECredit {credit :: LedgerCreditType} | LEDebit {debit ::
-- confirmed
data LedgerCreditType
= CTPayment {invoiceId :: Int64}
| CTCharge {chargeId :: Int64}
= CTPayment {invoiceId :: InvoiceId}
| CTCode
| CTCharge {chargeId :: Text}
| CTSupport
| CTTransferIn {fromPurchaseId :: Maybe Int64}
| CTOpening
@@ -107,6 +109,31 @@ data LedgerDebitType
data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded
deriving (Eq, Show)
instance TextEncoding BadgeAlertKind where
textEncode = \case
BARenewalApproaching -> "renewal_approaching"
BAPaymentIssue -> "payment_issue"
BASubscriptionEnded -> "subscription_ended"
BAPrepaidEnding -> "prepaid_ending"
BASupportEnded -> "support_ended"
textDecode = \case
"renewal_approaching" -> Just BARenewalApproaching
"payment_issue" -> Just BAPaymentIssue
"subscription_ended" -> Just BASubscriptionEnded
"prepaid_ending" -> Just BAPrepaidEnding
"support_ended" -> Just BASupportEnded
_ -> Nothing
instance FromField BadgeAlertKind where fromField = fromTextField_ textDecode
instance ToField BadgeAlertKind where toField = toField . textEncode
-- exactly one of these funds a purchase; the schema cannot say so, both columns being nullable
data BadgeFunding
= BFPayment {paymentId :: PaymentId}
| BFCodeRedemption {redemptionId :: Int64}
deriving (Eq, Show)
-- to review
data BadgePurchase = BadgePurchase
{ badgePurchaseId :: Int64,
@@ -117,7 +144,7 @@ data BadgePurchase = BadgePurchase
badgeType :: BadgeType,
priceId :: Maybe BadgePriceId,
offerId :: Maybe BadgeOfferId,
paymentId :: Int64,
funding :: BadgeFunding,
status :: BadgePurchaseStatus,
credential :: Maybe BadgeCredential,
alertAcked :: Maybe (BadgeAlertKind, Text),
@@ -134,6 +161,7 @@ data BadgeLedgerEntry = BadgeLedgerEntry
changeMonths :: Int,
balanceMonths :: Int,
balanceStartTs :: UTCTime,
balanceAnchorTs :: UTCTime,
balanceBadgeType :: BadgeType,
wasPausedSince :: Maybe UTCTime,
serviceCreatedAt :: UTCTime,
@@ -156,14 +184,16 @@ data BadgeCharge = BadgeCharge
}
deriving (Show)
-- unconfirmed draft
-- every issuance covers one month and is written beside exactly one debit(badge) row
data BadgeIssuance = BadgeIssuance
{ issuanceId :: Int64,
{ issuanceId :: Text,
badgePurchaseId :: Int64,
periodStart :: Maybe UTCTime,
periodEnd :: Maybe UTCTime,
expiry :: Maybe UTCTime,
entryId :: Maybe Int64,
badgeType :: BadgeType,
periodStart :: UTCTime,
periodEnd :: UTCTime,
expiry :: UTCTime,
entryId :: Int64,
credential :: BadgeCredential,
createdAt :: UTCTime
}
deriving (Show)
@@ -177,17 +207,19 @@ data BadgeAlert = BadgeAlert
}
deriving (Show)
-- unconfirmed draft
data UserBadgeState = UserBadgeState
{ badges :: [BadgePurchase],
shownBadgeId :: Maybe Int64,
payments :: [StoredPayment],
-- | The user's badge as the badge surfaces render it. The purchase keys are deliberately absent:
-- this travels to the UI and over remote control, and they are secrets that stay in core.
data BadgeState = BadgeState
{ badgePurchaseId :: Int64,
badgeType :: BadgeType,
monthsLeft :: Int,
paidThrough :: Maybe UTCTime,
paidThrough :: UTCTime,
-- payments returns here with the payment types, which this slice neither writes nor encodes
renewsAt :: Maybe UTCTime,
willRenew :: Bool,
alert :: Maybe BadgeAlert
}
deriving (Show)
instance TextEncoding BadgePurchaseStatus where
textEncode = \case
@@ -224,3 +256,14 @@ instance ToField BadgeCodePaymentStatus where toField = toField . textEncode
$(JQ.deriveJSON (enumJSON $ dropPrefix "BIS") ''BadgeItemStatus)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount)
instance ToJSON BadgeAlertKind where
toJSON = textToJSON
toEncoding = textToEncoding
instance FromJSON BadgeAlertKind where
parseJSON = textParseJSON "BadgeAlertKind"
$(JQ.deriveJSON defaultJSON ''BadgeAlert)
$(JQ.deriveJSON defaultJSON ''BadgeState)
+22
View File
@@ -84,6 +84,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Chat.Badges (BadgeCredential, LocalBadge)
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..))
import Simplex.Messaging.Crypto.BBS (BBSPublicKey)
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import qualified Simplex.Messaging.Crypto.File as CF
@@ -92,6 +93,7 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer)
import Simplex.Messaging.Session (SessionVar)
import Simplex.Messaging.TMap (TMap)
import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion)
import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost)
@@ -145,6 +147,10 @@ data ChatConfig = ChatConfig
badgePublicKeys :: Map Int BBSPublicKey,
-- Nothing until the badge service is deployed
badgeServiceAddress :: Maybe (ConnectTarget 'CMContact),
-- the only clock badge code reads, so tests can shift it; production arithmetic is unchanged
badgeCurrentTime :: IO UTCTime,
-- how long a badge worker waits before repeating a renewal that failed for a passing reason
badgeRetryInterval :: RetryInterval,
confirmMigrations :: MigrationConfirmation,
presetServers :: PresetServers,
shortLinkPresetServers :: NonEmpty SMPServer,
@@ -278,6 +284,12 @@ defaultInlineFilesConfig =
data ChatDatabase = ChatDatabase {chatStore :: DBStore, agentStore :: DBStore}
-- | Signalling badgeWork wakes the worker from its own wait; a full var means it has work to do.
data BadgeWorker = BadgeWorker
{ badgeWorkerAsync :: Async (),
badgeWork :: TMVar ()
}
data ChatController = ChatController
{ currentUser :: TVar (Maybe User),
randomPresetServers :: NonEmpty PresetOperator,
@@ -310,6 +322,9 @@ data ChatController = ChatController
deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker,
deliveryJobWorkers :: TMap DeliveryWorkerKey Worker,
relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework
-- one badge worker per user: badge state is per profile, and one profile must not stall another
badgeWorkers :: TMap UserId (SessionVar BadgeWorker),
badgeSeq :: TVar Int,
relayGroupLinkChecksAsync :: TVar (Maybe (Async ())),
webPreviewState :: Maybe WebPreviewState,
chatRelayTests :: TMap ConnId RelayTest,
@@ -641,6 +656,10 @@ data ChatCommand
| UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file
| AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`)
| APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service
| APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert
-- episode is last because it is free text: it is the value that makes one occurrence of an
-- alert distinct from the next, and the app returns whatever it was given
| APIAckBadgeAlert {userId :: UserId, badgePurchaseId :: Int64, alertKind :: BadgeAlertKind, snooze :: Bool, episode :: Text}
| ShowProfileImage
| SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI)
| SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed)
@@ -846,6 +865,7 @@ data ChatResponse
| CRServiceResponse {user :: User, responseData :: J.Object}
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
| CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool}
| CRBadgeState {user :: User, badgeState :: Maybe BadgeState}
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
| CRGroupsList {user :: User, groups :: [GroupInfo]}
@@ -963,6 +983,8 @@ data ChatEvent
| CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat}
| CEvtServiceRequest {user :: User, requestId :: AgentInvId, signerKey :: Maybe C.PublicKeyEd25519, requestData :: J.Object}
| CEvtServiceReplySent {connectionId :: AgentConnId}
| CEvtBadgeChanged {user :: User, badgeState :: Maybe BadgeState} -- badge state changed, including a renewal that arrived without a command
| CEvtBadgeAlert {user :: User, badgeAlert :: BadgeAlert}
| CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason}
| CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response
| CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo}
+400 -33
View File
@@ -51,14 +51,19 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime, getCurrentTime, nominalDay)
import Data.Word (Word32)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay)
import Data.Type.Equality
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
import Simplex.Chat.Library.Subscriber
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Crypto.Random (ChaChaDRG)
import Simplex.Messaging.Session (SessionVar (..), withGetSessVar')
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import qualified Simplex.Chat.Badges.Ledger as L
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeState (..))
import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode)
import Simplex.Chat.Badges.Service (BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), currentBadgeServiceVersion)
import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion)
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
import Simplex.Chat.Call
import Simplex.Chat.Controller
@@ -100,6 +105,7 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHar
import Simplex.Messaging.Agent
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), withRetryInterval)
import Simplex.Messaging.Agent.Store.Entity
import Simplex.Messaging.Agent.Store.Interface (execSQL)
import Simplex.Messaging.Agent.Store.Shared (upMigration)
@@ -253,6 +259,7 @@ startChatController mainApp enableSndFiles serviceRequests = do
startDeliveryWorkers
startRelayRequestWorker_
startCleanupManager
mapM_ startBadgeWork users
void $ forkIO $ mapM_ startExpireCIs users
startRelayChecks users
startWebPreview users
@@ -349,7 +356,8 @@ restoreCalls = do
atomically $ writeTVar calls callsMap
stopChatController :: ChatController -> IO ()
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, badgeWorkers} = do
stopBadgeWorkers badgeWorkers
readTVarIO remoteHostSessions >>= mapM_ (cancelRemoteHost False . snd)
atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (cancelRemoteCtrl False . snd)
disconnectAgentClient smpAgent
@@ -581,6 +589,7 @@ processChatCommand cxt nm = \case
void . forkIO $ subscribeUsers True users
void . forkIO $ startFilesToReceive users
setAllExpireCIFlags True
mapM_ startBadgeWork users
ok_
APISuspendChat t -> do
chatWriteVar chatActivated False
@@ -3538,6 +3547,17 @@ processChatCommand cxt nm = \case
ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile)
AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user
APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText
APIGetBadgeState userId -> withUserId userId $ \user -> do
-- the read also signals the worker, whose results follow as CEvtBadgeChanged
lift $ startBadgeWork user
CRBadgeState user <$> getUserBadgeState user
APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do
now <- badgeNow
let snoozeUntil = if snooze then Just (addUTCTime nominalDay now) else Nothing
withStore' $ \db -> setBadgeAlertAcked db badgePurchaseId alertKind episode snoozeUntil
-- after the write, so the pass it signals arms a wake for the snooze rather than raising again
lift $ startBadgeWork user
CRBadgeState user <$> getUserBadgeState user
SetBotCommands commands -> withUser $ \user@User {profile} -> do
let LocalProfile {preferences} = profile
prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands}
@@ -3640,6 +3660,7 @@ processChatCommand cxt nm = \case
CLUserContact ucId -> "UserContact " <> tshow ucId
CLContactRequest crId -> "ContactRequest " <> tshow crId
CLFile fId -> "File " <> tshow fId
CLBadgeUser uId -> "BadgeUser " <> tshow uId
DebugEvent event -> toView event >> ok_
GetAgentSubsTotal userId -> withUserId userId $ \user -> do
users <- withStore' $ \db -> getUsers db
@@ -5135,12 +5156,16 @@ addUserBadge user cred@(BadgeCredential _ _ _ info) =
Just False -> throwCmdError "badge credential does not verify against configured key"
Just True -> do
now <- liftIO getCurrentTime
user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info)))
user' <- withFastStore $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info)))
presentUserBadgeToContacts user'
presentUserBadgeToContacts :: User -> CM ()
presentUserBadgeToContacts user'@User {profile = LocalProfile {localBadge}} = do
asks currentUser >>= atomically . (`writeTVar` Just user')
presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadge}} = do
-- a badge worker runs for every profile, not only the active one, so this refreshes the active
-- record where it is the same profile and must never switch to another
chatModifyVar currentUser $ \case
Just User {userId = activeId} | activeId == userId -> Just user'
active_ -> active_
lift $ withAgent' $ \a -> setUserEntitlement a (aUserId user') (badgeServerCredential localBadge)
cxt <- asks $ mkStoreCxt . config
contacts <- withFastStore' $ \db -> getUserContacts db cxt user'
@@ -5157,25 +5182,34 @@ presentUserBadgeToContacts user'@User {profile = LocalProfile {localBadge}} = do
-- stashed before the request is sent, so a retry reaches the service as the same signer.
-- A terminal answer drops the stash; a timeout keeps it.
redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse
redeemBadgeCode nm user codeText = do
redeemBadgeCode nm user@User {userId} codeText = do
code <- maybe (throwCmdError "invalid badge code") pure $ parseBadgeCode codeText
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure
g <- asks random
now <- liftIO getCurrentTime
let codeSent = badgeCodeText code
redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <-
withStore' $ \db ->
getBadgeCodeRedemption db user codeSent
>>= maybe (createBadgeCodeRedemption db g user codeSent now) pure
let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}}
respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req
case J.fromJSON (J.Object respData) of
J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData
J.Success BSPError {code = errCode} -> do
when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption)
throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode)
J.Success BSPBadgeCredential {credential = Just cred} -> storeRedeemedBadge user redemption cred
J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData
-- the guard, the request and the write are one section: without it two codes redeemed at once
-- both pass the guard and are both spent, for one badge
(present_, redeemed) <- withEntityLock "badgeRedeem" (CLBadgeUser userId) $ do
redemption_ <- withStore' $ \db -> getBadgeCodeRedemption db user codeSent
-- a code already redeemed here is allowed through: re-sending it returns the badge it bought
-- and adds nothing. Refused before its keys are stashed and before the request, so it stays unspent
replaying <- maybe (pure False) (\r -> withStore' $ \db -> isJust <$> getCodeBadgePurchase db r) redemption_
unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwCmdError "badge already active"
redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <-
maybe (withStore' $ \db -> createBadgeCodeRedemption db g user codeSent now) pure redemption_
let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}}
respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req
case J.fromJSON (J.Object respData) of
J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData
J.Success BSPError {code = errCode} -> do
when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption)
throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode)
J.Success BSPBadgeCredential {credential = Just cred, statement} -> storeRedeemedBadge user redemption cred statement
J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData
-- outside the badge lock: the chat lock must not be taken under it
mapM_ presentUserBadgeToContacts present_
pure redeemed
where
-- re-encoded, not shown as received: JSON escapes the control characters a terminal acts on
respJSON = LB.unpack . J.encode
@@ -5197,10 +5231,317 @@ badgeServiceErrorText = \case
where
errorCodeChar c = isAsciiLower c || isDigit c || c == '_'
-- | Verify the credential before writing anything; the purchase, its issuance and the profile's
-- badge go in one transaction, and contacts are told after it commits.
storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> CM ChatResponse
storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeExpiry}) =
-- | The only clock badge code reads, so a test can move the client and the service together.
badgeNow :: CM UTCTime
badgeNow = asks (badgeCurrentTime . config) >>= liftIO
-- | A signal carries nothing: each pass derives its work from stored state, so a signal lost or
-- duplicated changes no outcome.
startBadgeWork :: User -> CM' ()
startBadgeWork user = whenM (isJust <$> asks (badgeServiceAddress . config)) $ void $ getBadgeWorker user
-- | Exactly one caller starts the thread and the rest wait for it: the lookup and the create cannot
-- be one transaction, because starting a thread is not STM.
getBadgeWorker :: User -> CM' BadgeWorker
getBadgeWorker User {userId} = do
ws <- asks badgeWorkers
seq' <- asks badgeSeq
now <- liftIO getCurrentTime
withGetSessVar' seq' userId ws now startWorker signalWorker
where
startWorker v = do
badgeWork <- newTMVarIO ()
badgeWorkerAsync <- async $ void $ runExceptT $ runBadgeWorker userId badgeWork
let w = BadgeWorker {badgeWorkerAsync, badgeWork}
w <$ atomically (putTMVar (sessionVar v) w)
signalWorker v = do
w <- atomically $ readTMVar $ sessionVar v
w <$ atomically (void $ tryPutTMVar (badgeWork w) ())
-- | The alert last raised, so it is not repeated on every pass. The snooze is in the key because
-- kind and episode do not change when it lapses: the alert would match and stay silent until a restart.
type BadgeOccurrence = (BadgeAlertKind, Text, Maybe UTCTime)
-- | Nothing ends a pass, so a persistent fault is one attempt per stall interval rather than a hot
-- loop: every error returns a wake, and the wait is outside the retries.
runBadgeWorker :: UserId -> TMVar () -> CM ()
runBadgeWorker userId badgeWork = do
emitted <- newTVarIO Nothing
ri <- asks $ badgeRetryInterval . config
forever $ do
at_ <- withRetryInterval ri $ \_ loop -> do
lift waitChatStartedAndActivated
now <- badgeNow
let stalled = pure $ Just $ badgeStalledInterval `addUTCTime` now
updateUserBadge userId emitted now `catchAllErrors` retryBadgeError loop stalled
now <- badgeNow
liftIO $ waitBadgeWake badgeWork now at_
retryBadgeError :: CM a -> CM a -> ChatError -> CM a
retryBadgeError loop stalled e = eToView e >> if badgeErrorRetry e then loop else stalled
-- | The signal is taken only by the wait that reports it - the take and the timer read are one
-- transaction. now is the badge clock, so the remaining time counts down rather than re-reading it.
waitBadgeWake :: TMVar () -> UTCTime -> Maybe UTCTime -> IO ()
waitBadgeWake badgeWork now = \case
Nothing -> atomically $ takeTMVar badgeWork
Just at -> waitFor $ diffToMicroseconds $ min badgeMaxWake $ diffUTCTime at now
where
waitFor time
| time <= 0 = pure ()
| otherwise = do
let maxWait = min time $ fromIntegral (maxBound :: Int)
timer <- registerDelay $ fromIntegral maxWait
signalled <- atomically $ do
w <- tryTakeTMVar badgeWork
fired <- readTVar timer
unless (isJust w || fired) retry
pure $ isJust w
unless signalled $ waitFor $ time - maxWait
-- | Bounds the wait: a paidThrough far enough out would overflow the microsecond conversion,
-- wrap negative and spin the worker. Longer than any entitlement, so no real wake is early.
badgeMaxWake :: NominalDiffTime
badgeMaxWake = 100 * 365 * nominalDay
-- | Retire what has ended, renew what is due, then report the next wake. Waking early, late or not
-- at all changes only timing: each run reads stored state and works out what to do.
updateUserBadge :: UserId -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime)
updateUserBadge userId emitted now = do
user <- withStore $ \db -> getUser db userId
withStore' (`getUserBadgePurchase` user) >>= \case
Nothing -> pure Nothing
Just p@UserBadgePurchase {badgePurchaseId} ->
withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= \case
Nothing -> pure Nothing
Just balance -> do
-- retirement needs no service and an unbounded retry would not return before it
retired <- retireExpiredBadge user p now balance
latest <- withStore' (`getLatestIssuedCredential` badgePurchaseId)
let requestDue = not retired && badgeRequestDue now (shownBadgeCredential user p) latest balance
(balance', serviceAt) <-
if requestDue
then either ((balance,) . Just) (,Nothing) <$> requestBadgeIssue userId p now
else pure (balance, Nothing)
-- presenting broadcasts the record it is handed, and the request above can block for the
-- whole service timeout, so this read belongs after it and not at the top of the pass
user' <- withStore $ \db -> getUser db userId
-- and the purchase, or an alert acked while the request was in flight is raised again
p' <- fromMaybe p <$> withStore' (`getBadgePurchase` badgePurchaseId)
let issued = balanceStartTs balance' /= balanceStartTs balance
-- outside the badge lock: the chat lock must not be taken under it
unless retired $ presentIssuedBadge user' p' now
emitBadgeAlert user' emitted p' now balance'
-- retiring and presenting both replace the badge on the record read above, so it is read again
user'' <- withStore $ \db -> getUser db userId
when (retired || issued) $ toView . CEvtBadgeChanged user'' =<< getUserBadgeState user''
-- a snooze is the one wake that is not in the ledger: nothing else brings the alert back,
-- since support having ended leaves both ledger boundaries in the past
let UserBadgePurchase {alertSnoozeUntil} = p'
snoozeAt = find (> now) alertSnoozeUntil
stalledAt = if requestDue && not issued then Just $ badgeStalledInterval `addUTCTime` now else Nothing
pure $ earliestTime [serviceAt, snoozeAt, stalledAt, badgeBoundary now (shownBadgeCredential user'' p') balance']
-- | Support ended is the only alert raised here: the others need subscriptions, and warning before
-- a prepaid badge ends is not actionable while topping up cannot credit months without issuing.
-- TODO [badges] BAPrepaidEnding belongs here, three days before paidThrough, once that exists.
derivedBadgeAlert :: UTCTime -> StatementEntry -> Maybe BadgeAlert
derivedBadgeAlert now b
| balanceMonths b == 0 && endsAt <= now =
Just BadgeAlert {kind = BASupportEnded, episode = safeDecodeUtf8 $ strEncode endsAt, date = endsAt, price = Nothing}
| otherwise = Nothing
where
endsAt = L.paidThrough b
-- | Derived from state rather than kept pending: raised unless this occurrence is the one already
-- answered, and raised again once a snooze that answered it lapses.
unansweredBadgeAlert :: UTCTime -> UserBadgePurchase -> StatementEntry -> Maybe BadgeAlert
unansweredBadgeAlert now UserBadgePurchase {alertAcked, alertSnoozeUntil} balance =
case derivedBadgeAlert now balance of
Just alert@BadgeAlert {kind, episode}
| alertAcked /= Just (kind, episode) || maybe False (now >=) alertSnoozeUntil -> Just alert
_ -> Nothing
emitBadgeAlert :: User -> TVar (Maybe BadgeOccurrence) -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM ()
emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} now balance =
forM_ (unansweredBadgeAlert now p balance) $ \alert@BadgeAlert {kind, episode} -> do
let occurrence = Just (kind, episode, alertSnoozeUntil)
raised <- atomically $ stateTVar emitted (,occurrence)
when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert
-- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged.
getUserBadgeState :: User -> CM (Maybe BadgeState)
getUserBadgeState user = do
now <- badgeNow
withStore' (`getUserBadgePurchase` user) >>= \case
Nothing -> pure Nothing
Just p@UserBadgePurchase {badgePurchaseId} ->
fmap (badgeStateOf now p) <$> withStore' (`getBadgeLedgerLastEntry` badgePurchaseId)
where
badgeStateOf now p@UserBadgePurchase {badgePurchaseId, badgeType} balance =
BadgeState
{ badgePurchaseId,
badgeType,
monthsLeft = balanceMonths balance,
paidThrough = L.paidThrough balance,
renewsAt = Nothing,
willRenew = False,
alert = unansweredBadgeAlert now p balance
}
-- | How long a month that did not issue waits before it is tried again, whatever stopped it. Not
-- derived from the failure, so a misclassified one cannot leave a funded badge to expire.
badgeStalledInterval :: NominalDiffTime
badgeStalledInterval = nominalDay
-- | The wait after a service refusal, floored at initialInterval so answering 0 cannot spin the
-- worker, and uncapped above it.
badgeRetryAfter :: RetryInterval -> Maybe Word32 -> NominalDiffTime
badgeRetryAfter RetryInterval {initialInterval} = maybe badgeStalledInterval (max floorWait . fromIntegral)
where
floorWait = fromIntegral initialInterval / 1000000
-- | How far ahead of the shown credential's expiry the renewal is requested - a day, so a failure
-- has that long to retry. The wake and the due check both derive from it and have to agree.
badgeRequestLead :: NominalDiffTime
badgeRequestLead = nominalDay
-- | The credential the profile is showing for this purchase. Nothing when the purchase is not the
-- one being shown, or when a crash left the issuance written and the profile not.
shownBadgeCredential :: User -> UserBadgePurchase -> Maybe BadgeCredential
shownBadgeCredential User {profile = LocalProfile {localBadge}} UserBadgePurchase {shown}
| not shown = Nothing
| otherwise = case localBadge of
Just (OwnBadge cred _) -> Just cred
_ -> Nothing
credentialExpiry :: BadgeCredential -> UTCTime
credentialExpiry (BadgeCredential _ _ _ BadgeInfo {badgeExpiry}) = badgeExpiry
-- | Timed off the shown credential, not the period end: renewing around its shared expiry is what
-- joins the anonymity set. Latest still equal to shown means this month has not been asked for.
badgeRequestDue :: UTCTime -> Maybe BadgeCredential -> Maybe BadgeCredential -> StatementEntry -> Bool
badgeRequestDue now shownCred latestCred balance =
balanceMonths balance > 0 && latestCred == shownCred && maybe False lapsingSoon shownCred
where
lapsingSoon cred = credentialExpiry cred <= badgeRequestLead `addUTCTime` now
-- | The request and the presentation, a day apart, both read off the credential the profile shows,
-- and the end of what is paid for. The credential's expiry window is what covers renewal, so it
-- says nothing about entitlement: paidThrough is when that ends and the badge has to come off.
-- TODO [badges] every client whose credential shares an expiry requests at the same instant. Only
-- the expiry has to be shared, so the request could fall anywhere in its lead without splitting
-- the anonymity set - spreading the load, and any outage, off a single moment.
badgeBoundary :: UTCTime -> Maybe BadgeCredential -> StatementEntry -> Maybe UTCTime
badgeBoundary now shownCred balance = case filter (> now) moments of
[] -> Nothing
ts -> Just $ minimum ts
where
moments = L.paidThrough balance : maybe [] renewalMoments shownCred
renewalMoments cred =
let expiry = credentialExpiry cred
in [negate badgeRequestLead `addUTCTime` expiry, expiry]
earliestTime :: [Maybe UTCTime] -> Maybe UTCTime
earliestTime ts = case catMaybes ts of
[] -> Nothing
ts' -> Just $ minimum ts'
-- | Only a failure that can clear on its own is repeated; every other throw is terminal, and
-- repeating it would spin. Service errors are classified by retryAfter in requestBadgeIssue.
badgeErrorRetry :: ChatError -> Bool
badgeErrorRetry = \case
ChatErrorAgent {agentError} -> retryable agentError
_ -> False
where
-- an unanswered request is the likeliest renewal failure and temporaryOrHostError does not
-- cover it: that classifies reaching the server, and this timeout is the agent's own
retryable = \case
AGENT (A_SERVICE ASETimeout) -> True
e -> temporaryOrHostError e
-- | Ask the service for the month that is due and apply the response. A timeout writes nothing, so
-- the same request is sent again on the next pass. 'Left' is a service error, already reported, and
-- carries when to try again, since a service error is answered rather than thrown.
requestBadgeIssue :: UserId -> UserBadgePurchase -> UTCTime -> CM (Either UTCTime StatementEntry)
requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, purchaseKey, purchasePrivKey, masterKey} now = do
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure
withEntityLock "badgeIssue" (CLBadgeUser userId) $ do
user <- withStore $ \db -> getUser db userId
lastEntry <- withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no entry to assert") pure
let req =
BadgeServiceRequest
{ version = currentBadgeServiceVersion,
purchaseKey = Just purchaseKey,
request = BSCIssueBadge {balance = BadgeBalance {lastEntry}}
}
respData <- sendServiceRequestTo NRMBackground user sendTarget Nothing (Just purchasePrivKey) req
case J.fromJSON (J.Object respData) of
J.Success BSPBadgeCredential {credential, statement} -> do
cred_ <- verifyIssuedCredential masterKey credential
-- TODO [badges] the statement is applied either way, so a failed verification spends the
-- month with nothing to show for it; that needs an alert, not only a line in the log
g <- asks random
applied <- withStore' $ \db -> applyBadgeStatement db g badgePurchaseId statement cred_ now
unless applied $ eToView $ ChatError $ CEInternalError "issued badge credential has no ledger row to store it against"
Right <$> (withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no balance") pure)
J.Success BSPError {code, retryAfter} -> do
eToView $ ChatError $ CECommandError $ "badge service error: " <> T.unpack (badgeServiceErrorText code)
ri <- asks $ badgeRetryInterval . config
pure $ Left $ badgeRetryAfter ri retryAfter `addUTCTime` now
_ -> throwCmdError "unexpected badge service response"
-- | The signature covers the master key inside the credential, so it verifies no matter which key
-- that is - the credential is stored only when that key is also this purchase's.
verifyIssuedCredential :: BadgeMasterKey -> Maybe BadgeCredential -> CM (Maybe BadgeCredential)
verifyIssuedCredential _ Nothing = pure Nothing
verifyIssuedCredential masterKey (Just cred@(BadgeCredential _ credMasterKey _ _)) =
verifyOwnBadge cred >>= \case
Just True | credMasterKey == masterKey -> pure $ Just cred
Just True -> Nothing <$ eToView (ChatError $ CEInternalError "issued badge credential is for a different master key")
_ -> Nothing <$ eToView (ChatError $ CEInternalError "issued badge credential does not verify")
-- | Present the newest issued credential once the one on the profile has run out. It is written in
-- a separate transaction from the issuance, so a crash between the two is repaired at the next run.
presentIssuedBadge :: User -> UserBadgePurchase -> UTCTime -> CM ()
presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now
| not shown = pure ()
| otherwise = do
cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId)
forM_ cred_ $ \cred@(BadgeCredential _ _ _ info) ->
when (presentDue cred) $ do
user' <- withStore $ \db -> setUserBadge db user (Just $ OwnBadge cred (mkBadgeStatus now (Just True) info))
presentUserBadgeToContacts user'
where
shownCred = shownBadgeCredential user p
-- Held back until the shown credential lapses, so the broadcast does not correlate with the
-- request that produced it. Nothing shown at all is the state a lost profile write leaves.
presentDue cred = Just cred /= shownCred && maybe True ((<= now) . credentialExpiry) shownCred
-- | The visible half of "the badge expired".
retireExpiredBadge :: User -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM Bool
retireExpiredBadge user UserBadgePurchase {badgePurchaseId, shown} now balance
| not (shown && L.paidThrough balance <= now) = pure False
| otherwise = do
user' <- withStore $ \db -> do
liftIO $ clearShownBadge db user badgePurchaseId
setUserBadge db user Nothing
True <$ presentUserBadgeToContacts user'
-- | Waiting on the var rather than skipping an empty one is what catches a worker whose creator
-- had not filled it when the map was swapped out.
stopBadgeWorkers :: TM.TMap UserId (SessionVar BadgeWorker) -> IO ()
stopBadgeWorkers workers =
atomically (swapTVar workers M.empty) >>= mapM_ cancelBadgeWorker
where
cancelBadgeWorker v =
void $ forkIO $ atomically (badgeWorkerAsync <$> readTMVar (sessionVar v)) >>= uninterruptibleCancel
-- | Verify the credential before writing anything; the purchase, the statement's rows, the
-- issuance and the profile's badge go in one transaction. Answers the user to tell contacts about,
-- which the caller does once the badge lock is released.
storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse)
storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info) statement =
verifyOwnBadge cred >>= \case
Nothing -> throwCmdError "redeemed badge credential names an unknown badge key index"
Just False -> throwCmdError "redeemed badge credential does not verify against configured key"
@@ -5209,16 +5550,37 @@ storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCr
Just True | credMasterKey /= masterKey -> throwCmdError "redeemed badge credential is for a different master key"
Just True -> do
g <- asks random
now <- liftIO getCurrentTime
now <- badgeNow
let badge = OwnBadge cred (mkBadgeStatus now (Just True) info)
-- TODO [badges] copy the statement's ledger entries, and retire a previously held badge
(user', newBadge) <- withStore' $ \db -> do
newBadge <- createCodeBadgePurchase db g user redemption cred badgeExpiry now
-- TODO [badges] retire a previously held badge
(user', newBadge, applied) <- withStore $ \db -> do
(purchaseId, newBadge) <- liftIO $ createCodeBadgePurchase db user redemption cred now
applied <- liftIO $ applyBadgeStatement db g purchaseId statement (Just cred) now
-- a replay must not put a superseded badge back, or tell every contact again
user' <- if newBadge then setUserBadge db user (Just badge) else pure user
pure (user', newBadge)
when newBadge $ presentUserBadgeToContacts user'
pure $ CRBadgeRedeemed user' badge newBadge
user' <- if newBadge then setUserBadge db user (Just badge) else getUser db userId
pure (user', newBadge, applied)
unless applied $ eToView $ ChatError $ CEInternalError "redeemed badge credential has no ledger row to store it against"
-- nothing is due yet, but a pass is what arms the next wake, and this is the first purchase
lift $ startBadgeWork user'
pure (if newBadge then Just user' else Nothing, CRBadgeRedeemed user' badge newBadge)
-- | Store the statement's rows, then the credential against the badge debit row among them.
-- 'False' when that row cannot be found, which the caller reports rather than drop in silence.
applyBadgeStatement :: DB.Connection -> TVar ChaChaDRG -> Int64 -> BadgeStatement -> Maybe BadgeCredential -> UTCTime -> IO Bool
applyBadgeStatement db g purchaseId BadgeStatement {entries} cred_ now = do
tip <- getBadgeLedgerLastEntry db purchaseId
storeBadgeStatement db purchaseId tip entries now
case (,) <$> cred_ <*> issuedEntryId of
Nothing -> pure True
Just (cred, entryUuid) ->
getBadgeLedgerEntryId db purchaseId entryUuid >>= \case
Nothing -> pure False
Just entryId -> storeBadgeIssuance db g purchaseId entryId cred now
where
-- the credential belongs to the last month the statement issued
issuedEntryId = case [entryId | StatementEntry {entryId, entryType = SEDebit SDBadge} <- entries] of
[] -> Nothing
ids -> Just (last ids)
sendServiceRequestTo :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM J.Object
sendServiceRequestTo nm user sendTarget requestTimeout signKey request = do
@@ -5625,6 +5987,8 @@ chatCommandP =
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
"/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP),
"/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP),
"/_badge state " *> (APIGetBadgeState <$> A.decimal),
"/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP),
"/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP),
"/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP),
"/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType),
@@ -6044,6 +6408,9 @@ chatCommandP =
descr <- A.takeWhile1 isSpace *> (T.dropWhileEnd isSpace <$> textP) <|> pure ""
pure $ if T.null descr then Nothing else Just $ T.take 160 descr
textP = safeDecodeUtf8 <$> A.takeByteString
badgeAlertKindP = do
t <- A.takeTill (== ' ')
maybe (fail "bad badge alert kind") pure $ textDecode $ safeDecodeUtf8 t
pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' '))
verifyCodeP = safeDecodeUtf8 <$> A.takeWhile (\c -> isDigit c || c == ' ')
msgTextP = jsonP <|> textP
+229 -24
View File
@@ -7,10 +7,22 @@
module Simplex.Chat.Store.Badges
( BadgeCodeRedemption (..),
UserBadgePurchase (..),
getUserBadgePurchase,
getBadgePurchase,
userHasBadge,
setBadgeAlertAcked,
clearShownBadge,
getBadgeCodeRedemption,
createBadgeCodeRedemption,
deleteBadgeCodeRedemption,
createCodeBadgePurchase,
getCodeBadgePurchase,
storeBadgeIssuance,
getLatestIssuedCredential,
storeBadgeStatement,
getBadgeLedgerLastEntry,
getBadgeLedgerEntryId,
)
where
@@ -19,23 +31,26 @@ import Crypto.Random (ChaChaDRG)
import qualified Data.Aeson as J
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Badges
import Simplex.Chat.Badges.Types (BadgePurchaseStatus (..))
import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..))
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgePurchaseStatus (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util (maybeFirstRow, safeDecodeUtf8)
import Simplex.Messaging.Util (decodeJSON, maybeFirstRow, maybeFirstRow', safeDecodeUtf8)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
import Database.PostgreSQL.Simple (Only (..), (:.) (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..))
import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql)
#endif
@@ -90,21 +105,13 @@ deleteBadgeCodeRedemption db redemptionId =
|]
(redemptionId, redemptionId)
-- | The purchase a redeemed code created, its issuance, and the profile's pointer to it.
-- False when the code was already redeemed here: the service replays the credential it issued,
-- and that must add no purchase and leave the shown badge alone.
createCodeBadgePurchase :: DB.Connection -> TVar ChaChaDRG -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> UTCTime -> IO Bool
createCodeBadgePurchase db g User {userId} redemption credential expiry now =
-- | 'False' when the code was already redeemed here: the service replays the credential it
-- issued, and that must add no purchase and leave the shown badge alone.
createCodeBadgePurchase :: DB.Connection -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> IO (Int64, Bool)
createCodeBadgePurchase db User {userId} redemption credential now =
getCodeBadgePurchase db redemption >>= \case
Just _ -> pure False
Just purchaseId -> pure (purchaseId, False)
Nothing -> do
purchaseId <- insertPurchase
DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId)
pure True
where
BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption
BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential
insertPurchase = do
DB.execute
db
[sql|
@@ -114,19 +121,217 @@ createCodeBadgePurchase db g User {userId} redemption credential expiry now =
|]
(userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, PSIssued, redemptionId, now, now)
purchaseId <- insertedRowId db
DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId)
pure (purchaseId, True)
where
BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption
BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential
-- | The period comes from the ledger, the expiry from the credential, which runs a week longer.
-- 'False' means no issuance row was written, which the caller reports rather than drop in silence.
-- A replayed statement names a month already issued, and one month has one issuance.
storeBadgeIssuance :: DB.Connection -> TVar ChaChaDRG -> Int64 -> Int64 -> BadgeCredential -> UTCTime -> IO Bool
storeBadgeIssuance db g badgePurchaseId entryId credential now =
getIssuedPeriod db badgePurchaseId entryId >>= \case
Nothing -> pure False
Just (periodStart, periodEnd) -> do
issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 g)
-- TODO [badges] the credential's expiry stands in for the period end, which is up to a
-- week later, until the statement carries the real period
DB.execute
db
[sql|
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?)
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING
|]
(issuanceId, purchaseId, badgeType, now, expiry, expiry, Binary (LB.toStrict $ J.encode credential), now)
pure purchaseId
((issuanceId, badgePurchaseId, entryId, badgeType) :. (periodStart, periodEnd, badgeExpiry, Binary (LB.toStrict $ J.encode credential), now))
pure True
where
BadgeCredential {badgeInfo = BadgeInfo {badgeType, badgeExpiry}} = credential
getLatestIssuedCredential :: DB.Connection -> Int64 -> IO (Maybe BadgeCredential)
getLatestIssuedCredential db badgePurchaseId = do
rows <-
DB.query
db
[sql|
SELECT credential FROM badge_issuances
WHERE badge_purchase_id = ?
ORDER BY period_end DESC
LIMIT 1
|]
(Only badgePurchaseId)
pure $ case rows of
[Only (Binary bs)] -> J.decodeStrict' bs
_ -> Nothing
-- the start is read from the row before rather than by subtracting a month, which clips
getIssuedPeriod :: DB.Connection -> Int64 -> Int64 -> IO (Maybe (UTCTime, UTCTime))
getIssuedPeriod db badgePurchaseId entryId = do
rows <-
DB.query
db
[sql|
SELECT
(SELECT prev.balance_start_ts FROM badge_ledger prev
WHERE prev.badge_purchase_id = issued.badge_purchase_id AND prev.entry_id < issued.entry_id
ORDER BY prev.entry_id DESC LIMIT 1),
issued.balance_start_ts
FROM badge_ledger issued
WHERE issued.badge_purchase_id = ? AND issued.entry_id = ?
|]
(badgePurchaseId, entryId)
-- no preceding row means no credit was ever stored, so the period this row issued is unknown
pure $ case rows of
[(Just periodStart, periodEnd)] -> Just (periodStart, periodEnd)
_ -> Nothing
getCodeBadgePurchase :: DB.Connection -> BadgeCodeRedemption -> IO (Maybe Int64)
getCodeBadgePurchase db BadgeCodeRedemption {redemptionId} =
maybeFirstRow fromOnly $
DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId)
data UserBadgePurchase = UserBadgePurchase
{ badgePurchaseId :: Int64,
purchaseKey :: C.PublicKeyEd25519,
purchasePrivKey :: C.PrivateKeyEd25519,
masterKey :: BadgeMasterKey,
badgeType :: BadgeType,
shown :: Bool,
alertAcked :: Maybe (BadgeAlertKind, Text),
alertSnoozeUntil :: Maybe UTCTime
}
-- | Newest, not the one shown_badge_id points at - retirement clears that, and the support ended
-- alert is recomputed from this purchase after the badge stops being shown.
getUserBadgePurchase :: DB.Connection -> User -> IO (Maybe UserBadgePurchase)
getUserBadgePurchase db User {userId} =
maybeFirstRow fromOnly newestId >>= maybe (pure Nothing) (getBadgePurchase db)
where
newestId =
DB.query
db
[sql|
SELECT badge_purchase_id FROM badge_purchases
WHERE user_id = ? AND purchase_priv_key IS NOT NULL
ORDER BY badge_purchase_id DESC
LIMIT 1
|]
(Only userId)
-- shown is a CASE because in Postgres a comparison is boolean, which BoolInt rejects.
getBadgePurchase :: DB.Connection -> Int64 -> IO (Maybe UserBadgePurchase)
getBadgePurchase db purchaseId =
maybeFirstRow toPurchase $
DB.query
db
[sql|
SELECT p.badge_purchase_id, p.purchase_key, p.purchase_priv_key, p.master_key, p.current_badge_type,
(CASE WHEN u.shown_badge_id = p.badge_purchase_id THEN 1 ELSE 0 END),
p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until
FROM badge_purchases p
JOIN users u ON u.user_id = p.user_id
WHERE p.badge_purchase_id = ? AND p.purchase_priv_key IS NOT NULL
|]
(Only purchaseId)
where
toPurchase (badgePurchaseId, purchaseKey, purchasePrivKey, Binary mk, badgeType, shown_, ackedKind_, ackedEpisode_, alertSnoozeUntil) =
UserBadgePurchase
{ badgePurchaseId,
purchaseKey,
purchasePrivKey,
masterKey = BadgeMasterKey mk,
badgeType,
shown = unBI shown_,
alertAcked = (,) <$> ackedKind_ <*> ackedEpisode_,
alertSnoozeUntil
}
-- | Whether a badge is on the profile now: set when a redemption stores one, cleared when it is
-- retired. Read as the id rather than as a comparison, which in Postgres would be a boolean.
userHasBadge :: DB.Connection -> User -> IO Bool
userHasBadge db User {userId} =
maybeFirstRow' False shownBadge $
DB.query db "SELECT shown_badge_id FROM users WHERE user_id = ?" (Only userId)
where
shownBadge :: Only (Maybe Int64) -> Bool
shownBadge = isJust . fromOnly
-- | An ack and a snooze both record the occurrence answered; a snooze also records how long it
-- holds, so that it silences that occurrence and not whichever one is derived next.
setBadgeAlertAcked :: DB.Connection -> Int64 -> BadgeAlertKind -> Text -> Maybe UTCTime -> IO ()
setBadgeAlertAcked db badgePurchaseId kind episode snoozeUntil =
DB.execute
db
"UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ?"
(kind, episode, snoozeUntil, badgePurchaseId)
-- | Stop showing a badge that has expired unrenewed; the profile update is broadcast by the caller.
clearShownBadge :: DB.Connection -> User -> Int64 -> IO ()
clearShownBadge db User {userId} badgePurchaseId =
DB.execute db "UPDATE users SET shown_badge_id = NULL WHERE user_id = ? AND shown_badge_id = ?" (userId, badgePurchaseId)
-- | Verbatim, entry_uuid and type included: the client authors no row, or the two sides stop
-- holding the same ledger. DO NOTHING makes a re-applied statement a no-op rather than a throw.
-- An entry whose balance does not follow from the one before it is stored and marked, not refused.
storeBadgeStatement :: DB.Connection -> Int64 -> Maybe StatementEntry -> [StatementEntry] -> UTCTime -> IO ()
storeBadgeStatement db badgePurchaseId tip entries now =
mapM_ storeEntry $ balanceChecked tip entries
where
storeEntry (StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}, checked) =
DB.execute
db
[sql|
INSERT INTO badge_ledger
(entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
was_paused_since, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type,
entry_type_unknown, entry_type_value, balance_checked)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (entry_uuid) DO NOTHING
|]
( (entryId, badgePurchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince)
:. (createdAt, now, entryTypeT, creditType, debitType, BI typeUnknown, entryTypeValue, BI <$> checked)
)
where
(entryTypeT, creditType, debitType) = entryTypeColumns entryType
-- kept for every entry, not only for a type this version cannot decode: a tag alone does
-- not rebuild the types that name an invoice, a charge or another purchase
entryTypeValue = safeDecodeUtf8 . LB.toStrict $ case entryType of
SECredit c -> J.encode c
SEDebit d -> J.encode d
typeUnknown = case entryType of
SECredit SCUnknown {} -> True
SEDebit SDUnknown {} -> True
_ -> False
-- | The balance is the last row; nothing derives it by summing the history.
getBadgeLedgerLastEntry :: DB.Connection -> Int64 -> IO (Maybe StatementEntry)
getBadgeLedgerLastEntry db badgePurchaseId =
maybeFirstRow' Nothing toEntry $
DB.query
db
[sql|
SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
was_paused_since, service_created_at, entry_type, entry_credit_type, entry_debit_type, entry_type_value
FROM badge_ledger
WHERE badge_purchase_id = ?
ORDER BY entry_id DESC
LIMIT 1
|]
(Only badgePurchaseId)
where
toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) =
(\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType})
<$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_
-- | Decodes the stored JSON rather than rebuilding from the tag, so a version that has since
-- learnt the type reads it with its fields, and one that has not still gets it back verbatim.
entryTypeFromValue :: Text -> Text -> Maybe StatementEntryType
entryTypeFromValue entryTypeT value_ = case entryTypeT of
"credit" -> SECredit <$> decodeJSON value_
"debit" -> SEDebit <$> decodeJSON value_
_ -> Nothing
getBadgeLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64)
getBadgeLedgerEntryId db badgePurchaseId entryUuid =
maybeFirstRow fromOnly $
DB.query db "SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?" (badgePurchaseId, entryUuid)
@@ -143,6 +143,7 @@ CREATE TABLE @badge_ledger(
change_months SMALLINT NOT NULL,
balance_months SMALLINT NOT NULL,
balance_start_ts TIMESTAMPTZ NOT NULL,
balance_anchor_ts TIMESTAMPTZ NOT NULL,
balance_badge_type TEXT NOT NULL,
was_paused_since TIMESTAMPTZ,
service_created_at TIMESTAMPTZ NOT NULL,
@@ -183,6 +184,8 @@ CREATE TABLE @badge_issuances(
CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id);
CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id);
CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id);
|]
badgeSchemaTablesDown :: Text
@@ -190,6 +193,7 @@ badgeSchemaTablesDown =
[r|
DROP INDEX @idx_badge_issuances_purchase;
DROP INDEX @idx_badge_issuances_entry;
DROP INDEX @idx_badge_issuances_purchase_entry;
DROP TABLE @badge_issuances;
DROP INDEX @idx_badge_ledger_uuid;
DROP INDEX @idx_badge_ledger_purchase;
@@ -237,6 +241,8 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown SMALLINT NOT NULL DEFAULT
ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT;
ALTER TABLE badge_ledger ADD COLUMN balance_checked SMALLINT;
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;
@@ -223,6 +223,7 @@ CREATE TABLE test_chat_schema.badge_ledger (
change_months smallint NOT NULL,
balance_months smallint NOT NULL,
balance_start_ts timestamp with time zone NOT NULL,
balance_anchor_ts timestamp with time zone NOT NULL,
balance_badge_type text NOT NULL,
was_paused_since timestamp with time zone,
service_created_at timestamp with time zone NOT NULL,
@@ -235,7 +236,8 @@ CREATE TABLE test_chat_schema.badge_ledger (
from_purchase_id bigint,
to_purchase_id bigint,
entry_type_unknown smallint DEFAULT 0 NOT NULL,
entry_type_value text
entry_type_value text,
balance_checked smallint
);
@@ -2214,6 +2216,10 @@ CREATE INDEX idx_badge_issuances_purchase ON test_chat_schema.badge_issuances US
CREATE UNIQUE INDEX idx_badge_issuances_purchase_entry ON test_chat_schema.badge_issuances USING btree (badge_purchase_id, entry_id);
CREATE INDEX idx_badge_ledger_charge ON test_chat_schema.badge_ledger USING btree (charge_id);
+15 -13
View File
@@ -382,19 +382,21 @@ updateUserProfileFields_' db userId profileId Profile {displayName, fullName, sh
-- store the user's own badge credential; touches only the badge columns.
-- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message.
setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User
setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do
ts <- getCurrentTime
DB.execute
db
[sql|
UPDATE contact_profiles
SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
(localBadgeToRow localBadge :. (ts, userId, profileId))
DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId)
pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts}
-- answers the row as stored, or a profile edit landing since the caller's read is broadcast back stale.
setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> ExceptT StoreError IO User
setUserBadge db User {userId, profile = LocalProfile {profileId}} localBadge = do
liftIO $ do
ts <- getCurrentTime
DB.execute
db
[sql|
UPDATE contact_profiles
SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
(localBadgeToRow localBadge :. (ts, userId, profileId))
DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId)
getUser db userId
setUserSimplexDomain :: DB.Connection -> User -> Maybe SimplexDomain -> IO User
setUserSimplexDomain db user@User {userId, profile = p@LocalProfile {profileId}} domain_ = do
@@ -144,6 +144,7 @@ CREATE TABLE @badge_ledger(
change_months INTEGER NOT NULL,
balance_months INTEGER NOT NULL,
balance_start_ts TEXT NOT NULL,
balance_anchor_ts TEXT NOT NULL,
balance_badge_type TEXT NOT NULL,
was_paused_since TEXT,
service_created_at TEXT NOT NULL,
@@ -184,6 +185,8 @@ CREATE TABLE @badge_issuances(
CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id);
CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id);
CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id);
|]
badgeSchemaTablesDown :: Query
@@ -191,6 +194,7 @@ badgeSchemaTablesDown =
[sql|
DROP INDEX @idx_badge_issuances_purchase;
DROP INDEX @idx_badge_issuances_entry;
DROP INDEX @idx_badge_issuances_purchase_entry;
DROP TABLE @badge_issuances;
DROP INDEX @idx_badge_ledger_uuid;
DROP INDEX @idx_badge_ledger_purchase;
@@ -238,6 +242,8 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT
ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT;
ALTER TABLE badge_ledger ADD COLUMN balance_checked INTEGER;
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;
@@ -1204,8 +1204,19 @@ Plan:
SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?)
Query:
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?)
INSERT INTO badge_issuances (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING
Plan:
Query:
INSERT INTO badge_ledger
(entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
was_paused_since, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type,
entry_type_unknown, entry_type_value, balance_checked)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (entry_uuid) DO NOTHING
Plan:
@@ -2017,6 +2028,20 @@ Query:
Plan:
Query:
SELECT
(SELECT prev.balance_start_ts FROM badge_ledger prev
WHERE prev.badge_purchase_id = issued.badge_purchase_id AND prev.entry_id < issued.entry_id
ORDER BY prev.entry_id DESC LIMIT 1),
issued.balance_start_ts
FROM badge_ledger issued
WHERE issued.badge_purchase_id = ? AND issued.entry_id = ?
Plan:
SEARCH issued USING INTEGER PRIMARY KEY (rowid=?)
CORRELATED SCALAR SUBQUERY 1
SEARCH prev USING INDEX idx_badge_ledger_purchase (badge_purchase_id=? AND entry_id<?)
Query:
SELECT
-- Contact
@@ -3729,6 +3754,16 @@ Query:
Plan:
SEARCH cp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT credential FROM badge_issuances
WHERE badge_purchase_id = ?
ORDER BY period_end DESC
LIMIT 1
Plan:
SEARCH badge_issuances USING INDEX idx_badge_issuances_purchase_entry (badge_purchase_id=?)
USE TEMP B-TREE FOR ORDER BY
Query:
SELECT ct.contact_id
FROM contacts ct
@@ -3781,6 +3816,17 @@ Query:
Plan:
SEARCH contact_profiles USING INDEX idx_contact_profiles_user_id (user_id=?)
Query:
SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
was_paused_since, service_created_at, entry_type, entry_credit_type, entry_debit_type, entry_type_value
FROM badge_ledger
WHERE badge_purchase_id = ?
ORDER BY entry_id DESC
LIMIT 1
Plan:
SEARCH badge_ledger USING INDEX idx_badge_ledger_purchase (badge_purchase_id=?)
Query:
SELECT f.file_id
FROM files f
@@ -3981,6 +4027,20 @@ Plan:
SEARCH m USING INDEX idx_group_members_user_id (user_id=?)
SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT p.badge_purchase_id, p.purchase_key, p.purchase_priv_key, p.master_key, p.current_badge_type,
(CASE WHEN u.shown_badge_id = p.badge_purchase_id THEN 1 ELSE 0 END),
p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until
FROM badge_purchases p
JOIN users u ON u.user_id = p.user_id
WHERE p.user_id = ? AND p.purchase_priv_key IS NOT NULL
ORDER BY p.badge_purchase_id DESC
LIMIT 1
Plan:
SEARCH u USING INTEGER PRIMARY KEY (rowid=?)
SEARCH p USING INDEX idx_badge_purchases_user (user_id=?)
Query:
SELECT pgm.message_id, m.shared_msg_id, m.msg_body, m.msg_chat_binding, m.msg_signatures
FROM pending_group_messages pgm
@@ -7018,6 +7078,11 @@ Plan:
Query: INSERT INTO app_settings (app_settings) VALUES (?)
Plan:
Query: INSERT INTO badge_ledger (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_type_unknown, entry_type_value) SELECT 'unknown-entry', badge_purchase_id, 0, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, service_created_at, created_at, 'credit', 'grant', 1, '{"type":"grant"}' FROM badge_ledger ORDER BY entry_id DESC LIMIT 1
Plan:
SCAN badge_ledger
SEARCH badge_issuances USING COVERING INDEX idx_badge_issuances_entry (entry_id=?)
Query: INSERT INTO chat_item_mentions (chat_item_id, group_id, member_id, display_name) VALUES (?, ?, ?, ?)
Plan:
@@ -7205,6 +7270,10 @@ Query: SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv
Plan:
SEARCH connections USING INDEX idx_connections_conn_req_inv (user_id=? AND conn_req_inv=?)
Query: SELECT alert_acked_kind, alert_acked_episode FROM badge_purchases
Plan:
SCAN badge_purchases
Query: SELECT app_settings FROM app_settings
Plan:
SCAN app_settings
@@ -7217,6 +7286,14 @@ Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection
Plan:
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_proof IS NOT NULL ORDER BY contact_profile_id
Plan:
SCAN contact_profiles
Query: SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_signature IS NOT NULL ORDER BY contact_profile_id
Plan:
SCAN contact_profiles
Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?
Plan:
SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?)
@@ -7354,6 +7431,24 @@ Query: SELECT count(1) FROM pending_group_messages
Plan:
SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id
Query: SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?
Plan:
SEARCH badge_ledger USING INDEX idx_badge_ledger_uuid (entry_uuid=?)
Query: SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, COALESCE(entry_credit_type, entry_debit_type) FROM badge_ledger ORDER BY entry_id
Plan:
SCAN badge_ledger
Query: SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end
Plan:
SCAN badge_issuances
USE TEMP B-TREE FOR ORDER BY
Query: SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end DESC LIMIT 1
Plan:
SCAN badge_issuances
USE TEMP B-TREE FOR ORDER BY
Query: SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?
Plan:
SEARCH files USING INDEX idx_files_redirect_file_id (redirect_file_id=?)
@@ -7554,6 +7649,14 @@ Query: SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1
Plan:
SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT shown_badge_id FROM users WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT shown_badge_id, user_id FROM users WHERE user_id = 1
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT stored_roster_version FROM groups WHERE group_id = ?
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
@@ -7582,6 +7685,10 @@ Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ?
Plan:
SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE chat_items SET item_msg_body = ?, item_chat_binding = ?, item_signatures = ?, item_signed_by_group_member_id = ? WHERE chat_item_id = ? AND include_in_history = 1
Plan:
SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?)
@@ -7646,6 +7753,14 @@ Query: UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1
Plan:
SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE contact_profiles SET badge_expiry = ? WHERE badge_proof IS NOT NULL
Plan:
SCAN contact_profiles
Query: UPDATE contact_profiles SET badge_expiry = ? WHERE badge_signature IS NOT NULL
Plan:
SCAN contact_profiles
Query: UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ?
Plan:
SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?)
@@ -8034,6 +8149,10 @@ Query: UPDATE users SET shown_badge_id = ? WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE users SET shown_badge_id = NULL WHERE user_id = ? AND shown_badge_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE users SET ui_themes = ?, updated_at = ? WHERE user_id = ?
Plan:
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
@@ -963,6 +963,7 @@ CREATE TABLE badge_ledger(
change_months INTEGER NOT NULL,
balance_months INTEGER NOT NULL,
balance_start_ts TEXT NOT NULL,
balance_anchor_ts TEXT NOT NULL,
balance_badge_type TEXT NOT NULL,
was_paused_since TEXT,
service_created_at TEXT NOT NULL,
@@ -976,7 +977,8 @@ CREATE TABLE badge_ledger(
to_purchase_id INTEGER REFERENCES badge_purchases
,
entry_type_unknown INTEGER NOT NULL DEFAULT 0,
entry_type_value TEXT
entry_type_value TEXT,
balance_checked INTEGER
) STRICT;
CREATE TABLE badge_issuances(
issuance_id TEXT NOT NULL PRIMARY KEY,
@@ -1556,6 +1558,10 @@ CREATE INDEX idx_badge_issuances_purchase ON badge_issuances(
issuance_id
);
CREATE INDEX idx_badge_issuances_entry ON badge_issuances(entry_id);
CREATE UNIQUE INDEX idx_badge_issuances_purchase_entry ON badge_issuances(
badge_purchase_id,
entry_id
);
CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id);
CREATE INDEX idx_users_shown_badge ON users(shown_badge_id);
CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(
+1
View File
@@ -73,6 +73,7 @@ data ChatLockEntity
| CLUserContact Int64
| CLContactRequest Int64
| CLFile Int64
| CLBadgeUser Int64 -- one signed badge request per profile in flight
deriving (Eq, Ord)
-- These error type constructors must be added to mobile apps
+26 -1
View File
@@ -44,6 +44,7 @@ import Simplex.Chat.Help
import Simplex.Chat.Library.Commands (maxImageSize)
import Simplex.Chat.Markdown
import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus)
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeState (..))
import Simplex.Chat.Messages hiding (NewChatItem (..))
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Operators
@@ -190,6 +191,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)]
-- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be
CRBadgeRedeemed u badge newBadge -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"]
CRBadgeState u st -> ttyUser u $ viewUserBadgeState st
CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results
@@ -476,6 +478,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView}
<> maybe [] (\k -> [plain $ "signed by " <> safeDecodeUtf8 (strEncode k)]) sigKey_
<> ["request: " <> viewJSON req]
CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)]
CEvtBadgeChanged u st -> ttyUser u $ viewUserBadgeState st
CEvtBadgeAlert u alert -> ttyUser u $ viewBadgeAlert alert
CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"]
CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci
CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci
@@ -1829,9 +1833,30 @@ viewContactBadge = maybe [] $ \lb ->
BSExpiredOld -> "expired (old)"
BSFailed -> "verification failed"
BSUnknownKey -> "unknown key"
expiry = "expires " <> T.pack (formatTime defaultTimeLocale "%Y-%m-%d" badgeExpiry)
expiry = "expires " <> day badgeExpiry
in [plain (textEncode badgeType <> " badge - " <> st), plain expiry]
viewUserBadgeState :: Maybe BadgeState -> [StyledString]
viewUserBadgeState = maybe [] viewBadge
where
viewBadge BadgeState {badgePurchaseId, badgeType, monthsLeft, paidThrough, alert} =
plain
( tshow badgePurchaseId
<> ": "
<> textEncode badgeType
<> ", "
<> tshow monthsLeft
<> " months left, paid through "
<> day paidThrough
)
: maybe [] viewBadgeAlert alert
viewBadgeAlert :: BadgeAlert -> [StyledString]
viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date]
day :: UTCTime -> Text
day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d"
viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]
viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile =
["contact ID: " <> sShow contactId]
+290 -1
View File
@@ -9,18 +9,30 @@
module BadgeTests (badgeTests) where
import BadgeService.Service (badgeErrorRetryAfter)
import Control.Concurrent.STM (atomically)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay)
import Data.Time.Calendar (fromGregorian)
import Data.Time.Calendar.WeekDate (toWeekDate)
import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, nominalDay)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import Data.Maybe (fromMaybe, isNothing, maybeToList)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Chat.Badges
import Simplex.Chat.Badges.Code
import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service
import Simplex.Chat (defaultChatConfig)
import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent)
import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeRetryAfter, badgeStalledInterval)
import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..))
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay)
import Simplex.Messaging.Crypto.BBS
import Simplex.Messaging.Protocol (BrokerErrorType (..), NetworkError (..))
import Simplex.Messaging.Version.Internal (Version (..))
import Test.Hspec
@@ -39,6 +51,20 @@ badgeTests = do
it "reads a code as typed - any case, separators, ambiguous characters" testCodeNormalisation
it "rejects a code whose check character does not match" testCodeCheckCharacter
it "hashes the canonical form, whatever was typed" testCodeHash
describe "ledger transitions" $ do
it "issues a twelve month code one month at a time, and no thirteenth" testTwelveMonths
it "lapses only the elapsed months after a gap, leaving paidThrough unchanged" testLapseAfterGap
it "keeps the balance non-negative and the start non-decreasing" testLedgerInvariants
it "credits an exhausted balance from the grant, not from the date it ran out" testGrantAfterExhausted
it "does not issue months topped up inside an issued period" testGrantInsideIssuedPeriod
it "clips month ends without losing the issued period start" testMonthEndClipping
it "expires at the end of the Monday after the period" testMondayExpiry
it "stores the wire tag of every entry type, and rebuilds each from its stored JSON" testEntryTypeColumns
describe "worker retry" $ do
it "repeats a failure that can clear on its own, and no other" testRetryClassification
it "backs off to the cap" testRetryBackoff
it "floors the wait a service asks for, and honours anything above it" testServiceRetryFloor
it "sends retryAfter with the transient service codes and no other" testServiceRetryAfter
describe "service protocol JSON" $ do
it "redeemBadgeCode request matches the schema" testRedeemRequestJSON
it "badgeCredential response matches the schema" testCredentialResponseJSON
@@ -48,6 +74,9 @@ badgeTests = do
proofOf :: BadgeProof -> BBSProof
proofOf (BadgeProof _ _ p _) = p
nonDecreasing :: Ord a => [a] -> Bool
nonDecreasing xs = and $ zipWith (<=) xs (drop 1 xs)
testKeyIdx :: Int
testKeyIdx = 1
@@ -204,6 +233,260 @@ testCodeHash = do
Just typed <- pure $ parseBadgeCode $ T.toLower $ formatBadgeCode code
badgeCodeHash typed `shouldBe` badgeCodeHash code
-- Ledger transitions, against plans/2026-07-30-supporter-badges-v3-ux.md §3
at :: Integer -> Int -> Int -> UTCTime
at y m d = UTCTime (fromGregorian y m d) (11 * 3600)
newBalance :: UTCTime -> StatementEntry
newBalance t = emptyEntry t BTSupporter
-- these tests never write, so the id each operation stamps on its entry is never read
grant :: UTCTime -> Int -> StatementEntry -> StatementEntry
grant t n = grantEntry t "" n SCCode
lapse :: UTCTime -> StatementEntry -> Maybe StatementEntry
lapse t = lapseEntry t ""
issue :: UTCTime -> StatementEntry -> Maybe StatementEntry
issue t = issueEntry t ""
-- StatementEntry and BadgeInfo carry fields of the same names, so the selectors are ambiguous here
bMonths :: StatementEntry -> Int
bMonths StatementEntry {balanceMonths} = balanceMonths
bStart :: StatementEntry -> UTCTime
bStart StatementEntry {balanceStartTs} = balanceStartTs
-- one service pass: lapse what elapsed, then issue if a month is due, as the service chains them.
-- The period an issue covers is the previous entry's balance start to its own, so a run of starts
-- is what the period assertions read.
pass :: UTCTime -> StatementEntry -> [StatementEntry]
pass t e0 = maybeToList lapsed <> maybeToList (issue t $ fromMaybe e0 lapsed)
where
lapsed = lapse t e0
finalBalance :: StatementEntry -> [StatementEntry] -> StatementEntry
finalBalance e0 rows = last (e0 : rows)
-- each pass issues exactly one month, so the entries returned are the months issued, in order
issueAll :: StatementEntry -> [StatementEntry]
issueAll e = case pass (bStart e) e of
[] -> []
rows -> let e' = finalBalance e rows in e' : issueAll e'
testTwelveMonths :: IO ()
testTwelveMonths = do
let start = at 2026 3 10
granted = grant start 12 (newBalance start)
-- each month is issued as soon as it falls due
issued = issueAll granted
spent = finalBalance granted issued
length issued `shouldBe` 12
bMonths spent `shouldBe` 0
-- no month was skipped or issued twice: consecutive starts, so the periods tile the whole year
map bStart (granted : issued) `shouldBe` map (\m -> addMonths m start) [0 .. 12]
bStart spent `shouldBe` at 2027 3 10
-- a thirteenth request issues nothing, whenever it is made
issue (bStart spent) spent `shouldSatisfy` isNothing
issue (at 2030 1 1) spent `shouldSatisfy` isNothing
lapse (at 2030 1 1) spent `shouldSatisfy` isNothing
-- 3 months bought 10 Mar, first issued the same day, no pass until 20 May: April lapses unissued
testLapseAfterGap :: IO ()
testLapseAfterGap = do
let start = at 2026 3 10
granted = grant start 3 (newBalance start)
rows1 = pass start granted
afterFirst = finalBalance granted rows1
rows2 = pass (at 2026 5 20) afterFirst
afterSecond = finalBalance afterFirst rows2
map bMonths rows1 `shouldBe` [2]
-- March is issued: from the granting entry's start to the issued entry's own
bStart granted `shouldBe` at 2026 3 10
map bStart rows1 `shouldBe` [at 2026 4 10]
-- one lapse row for April, then May is issued: two rows, not one and not three. The lapse row's
-- start is where May begins, so it is also the start of the period the issue that follows covers
map bMonths rows2 `shouldBe` [1, 0]
map bStart rows2 `shouldBe` [at 2026 5 10, at 2026 6 10]
-- a lapse moves months from unused to gone; it never changes what was paid for
map paidThrough (granted : rows1 <> rows2) `shouldBe` replicate 4 (at 2026 6 10)
bMonths afterSecond `shouldBe` 0
data LedgerStep = Grant UTCTime Int | Pass UTCTime
testLedgerInvariants :: IO ()
testLedgerInvariants = do
let start = at 2026 1 15
reopened = at 2027 9 9
-- the April grant lands exactly where coverage ended and continues the run, the 2027 one
-- lands past it and restarts, so both branches of grantEntry run under the invariants
steps =
[ Grant start 3,
Pass start,
Pass (at 2026 4 15),
Grant (at 2026 4 15) 2,
Pass (at 2026 5 1),
Pass reopened,
Grant reopened 1,
Pass reopened
]
step (e, rows) = \case
Grant t n -> let e' = grant t n e in (e', rows <> [e'])
Pass t -> let rs = pass t e in (finalBalance e rs, rows <> rs)
(_, allRows) = foldl step (newBalance start, []) steps
map bMonths allRows `shouldSatisfy` all (>= 0)
map bStart allRows `shouldSatisfy` nonDecreasing
testGrantAfterExhausted :: IO ()
testGrantAfterExhausted = do
-- the balance ran out on 10 Feb; the next code is redeemed on 1 Jun
let spent = newBalance (at 2026 2 10)
granted = grant (at 2026 6 1) 2 spent
bStart granted `shouldBe` at 2026 6 1
paidThrough granted `shouldBe` at 2026 8 1
-- the four unsupported months are not backfilled, so nothing lapses immediately
lapse (at 2026 6 1) granted `shouldSatisfy` isNothing
testGrantInsideIssuedPeriod :: IO ()
testGrantInsideIssuedPeriod = do
let start = at 2026 1 10
granted = grant start 1 (newBalance start)
issued = finalBalance granted $ pass start granted
-- topped up on 20 Jan, while the month issued on 10 Jan still runs
toppedUp = grant (at 2026 1 20) 3 issued
-- February is where the next period starts, the top-up having been spent on neither January nor a gap
bStart toppedUp `shouldBe` at 2026 2 10
paidThrough toppedUp `shouldBe` at 2026 5 10
-- the balance starts in the future, so no second credential is issued for January
issue (at 2026 1 20) toppedUp `shouldSatisfy` isNothing
fmap bStart (issue (at 2026 2 10) toppedUp) `shouldBe` Just (at 2026 3 10)
testMonthEndClipping :: IO ()
testMonthEndClipping = do
let start = at 2027 1 31
granted = grant start 3 (newBalance start)
issued = issueAll granted
-- February clips to the 28th, and March goes back to the 31st: clipping does not accumulate.
-- Each period runs from one start to the next, so these bounds are the three periods
map bStart (granted : issued) `shouldBe` [at 2027 1 31, at 2027 2 28, at 2027 3 31, at 2027 4 30]
-- the issued period start is the previous balance start, never periodEnd minus a month, which
-- clipping would answer as 28 Jan
addMonths (-1) (bStart (head issued)) `shouldNotBe` bStart granted
-- across a leap day
let leap = grant (at 2028 1 29) 2 (newBalance (at 2028 1 29))
map bStart (issueAll leap) `shouldBe` [at 2028 2 29, at 2028 3 29]
-- a month that ends on the leap day counts as elapsed the moment it ends, and not before
fmap bMonths (lapse (at 2028 2 29) leap) `shouldBe` Just 1
lapse (addUTCTime (-1) (at 2028 2 29)) leap `shouldSatisfy` isNothing
-- buying a month at a time keeps the day of month that buying three at once keeps
let jan = grant (at 2027 1 31) 1 (newBalance (at 2027 1 31))
case issue (at 2027 1 31) jan of
Just issuedJan -> do
let feb = grant (at 2027 2 20) 1 issuedJan
fmap bStart (issue (at 2027 2 28) feb) `shouldBe` Just (at 2027 3 31)
Nothing -> expectationFailure "January was not issued"
testMondayExpiry :: IO ()
testMondayExpiry = do
-- the end of Monday 13 Apr is Tuesday 14 Apr 00:00
endOfMondayAfter (at 2026 4 10) `shouldBe` UTCTime (fromGregorian 2026 4 14) 0
endOfMondayAfter (at 2026 6 10) `shouldBe` UTCTime (fromGregorian 2026 6 16) 0
-- a period ending on a Monday still runs to the end of the following Monday, never to zero days
endOfMondayAfter (at 2026 4 13) `shouldBe` UTCTime (fromGregorian 2026 4 21) 0
let periodEnds = map (\d -> at 2026 4 d) [1 .. 30]
expiries = map endOfMondayAfter periodEnds
-- every expiry is a Tuesday midnight more than a day after its period, and at most eight
expiries `shouldSatisfy` all (\(UTCTime d t) -> t == 0 && (\(_, _, wd) -> wd == 2) (toWeekDate d))
zipWith diffUTCTime expiries periodEnds `shouldSatisfy` all (\d -> d > nominalDay && d <= 8 * nominalDay)
-- A failed renewal is otherwise left until the next chat start or activate, which on a desktop
-- left running can be days - long enough for a funded badge to lapse.
testRetryClassification :: IO ()
testRetryClassification = do
let retryFor = badgeErrorRetry . chatErrorAgent
-- an unanswered request is the likeliest renewal failure, and it is the agent's own error
retryFor (AGENT (A_SERVICE ASETimeout)) `shouldBe` True
retryFor (BROKER "localhost" TIMEOUT) `shouldBe` True
retryFor (BROKER "localhost" (NETWORK NETimeoutError)) `shouldBe` True
-- terminal: the same request would fail the same way, and repeating it would spin
retryFor (AGENT (A_SERVICE ASEBadSignature)) `shouldBe` False
retryFor (AGENT (A_SERVICE (ASERejected "no"))) `shouldBe` False
badgeErrorRetry (ChatError (CECommandError "unexpected badge service response")) `shouldBe` False
-- A failure that never clears is repeated for as long as the balance funds a month, so the wait
-- has to grow: at a fixed interval an annual code would ask hundreds of times a day, all year.
testRetryBackoff :: IO ()
testRetryBackoff = do
let ri@RetryInterval {initialInterval, maxInterval} = badgeRetryInterval defaultChatConfig
advance (elapsed, delay) =
let elapsed' = elapsed + delay
in (elapsed', nextRetryDelay elapsed' delay ri)
delays = map snd $ take 40 $ iterate advance (0, initialInterval)
head delays `shouldBe` initialInterval
delays `shouldSatisfy` all (\d -> d >= initialInterval && d <= maxInterval)
delays `shouldSatisfy` nonDecreasing
-- it reaches the cap rather than creeping towards it, and stays there
last delays `shouldBe` maxInterval
-- A service answering retryAfter 0 would put the next attempt at now, and the worker would ask
-- again as fast as the round trip allows, for as long as the service kept answering that way.
testServiceRetryFloor :: IO ()
testServiceRetryFloor = do
let ri@RetryInterval {initialInterval, maxInterval} = badgeRetryInterval defaultChatConfig
floorWait = fromIntegral initialInterval / 1000000 :: NominalDiffTime
aboveCap = 2 * fromIntegral maxInterval / 1000000 :: NominalDiffTime
-- a code carrying no wait is terminal for this request, and waits what any stalled month waits
badgeRetryAfter ri Nothing `shouldBe` badgeStalledInterval
-- nothing the service names brings the wait below where a retry of its own would start
badgeRetryAfter ri (Just 0) `shouldBe` floorWait
badgeRetryAfter ri (Just 1) `shouldBe` floorWait
badgeRetryAfter ri (Just $ round floorWait) `shouldBe` floorWait
-- above that it is honoured as sent, and not capped: a service may know it is down for the day
badgeRetryAfter ri (Just 600) `shouldBe` 600
badgeRetryAfter ri (Just $ round aboveCap) `shouldBe` aboveCap
-- badges-rpc.md defines retryAfter as marking the transient codes, and every other code as
-- terminal for the command attempted. The client repeats a code that carries one on the service's
-- schedule, so the set is the protocol's and not a judgement to make per call site.
testServiceRetryAfter :: IO ()
testServiceRetryAfter = do
badgeErrorRetryAfter BSEPaymentPending `shouldBe` Just 300
badgeErrorRetryAfter BSEProviderUnavailable `shouldBe` Just 300
badgeErrorRetryAfter BSERateLimited `shouldBe` Just 60
-- internal is the one most likely to clear on its own, and is still terminal: repeating it on
-- the service's cadence presses a service already failing, and the client has its own floor
badgeErrorRetryAfter BSEInternal `shouldBe` Nothing
mapM_
(\code -> badgeErrorRetryAfter code `shouldBe` Nothing)
[BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEUnknown "future_code"]
-- The client replicates entry_credit_type / entry_debit_type verbatim, so a stored tag that
-- disagreed with the wire tag would put a different row on each side.
testEntryTypeColumns :: IO ()
testEntryTypeColumns = do
k <- fst <$> (C.newRandom >>= \g -> atomically (C.generateKeyPair g) :: IO (C.KeyPair 'C.Ed25519))
let credits = [SCPayment Nothing, SCCode, SCCharge "ch1", SCSupport, SCTransferIn k, SCOpening]
debits = [SDRefund, SDUpgrade k, SDTransferOut k, SDSupport, SDBadge, SDLapse]
mapM_ (\c -> wireTag (J.toJSON (SECredit c)) "credit" `shouldBe` Just (creditTypeTag c)) credits
mapM_ (\d -> wireTag (J.toJSON (SEDebit d)) "debit" `shouldBe` Just (debitTypeTag d)) debits
-- the three types this version writes survive a round trip through the columns
mapM_
(\t -> uncurry3 entryTypeFromColumns (entryTypeColumns t) `shouldSatisfy` sameEntryType t)
[SECredit SCCode, SEDebit SDBadge, SEDebit SDLapse]
-- a type that needs a reference column is not silently read back as something else
uncurry3 entryTypeFromColumns (entryTypeColumns (SECredit (SCCharge "ch1"))) `shouldSatisfy` isNothing
-- which is why every type is stored as its own JSON as well, and read from that first: the
-- columns alone would answer a row naming an invoice or a purchase as no row at all
mapM_ roundTrips credits
mapM_ roundTrips debits
where
uncurry3 f (a, b, c) = f a b c
sameEntryType t = maybe False ((J.toJSON t ==) . J.toJSON)
wireTag v fld = case v of
J.Object o | Just (J.Object inner) <- KM.lookup fld o, Just (J.String t) <- KM.lookup "type" inner -> Just t
_ -> Nothing
-- Service protocol JSON, against docs/protocol/badges-rpc.schema.json
testRedeemRequestJSON :: IO ()
@@ -255,6 +538,7 @@ testStatementJSON = do
changeMonths = 3,
balanceMonths = 3,
balanceStartTs = futureTime,
balanceAnchorTs = futureTime,
balanceBadgeType = BTSupporter,
wasPausedSince = Nothing,
createdAt = futureTime,
@@ -267,6 +551,7 @@ testStatementJSON = do
"changeMonths" J..= (3 :: Int),
"balanceMonths" J..= (3 :: Int),
"balanceStartTs" J..= futureTime,
"balanceAnchorTs" J..= futureTime,
"balanceBadgeType" J..= ("supporter" :: T.Text),
"createdAt" J..= futureTime,
"entryType" J..= entryType entry
@@ -274,6 +559,10 @@ testStatementJSON = do
J.toJSON entry {wasPausedSince = Just pastTime} `shouldNotBe` J.toJSON entry
J.toJSON (entryType entry) `shouldBe` J.object ["type" J..= ("credit" :: T.Text), "credit" J..= J.object ["type" J..= ("payment" :: T.Text)]]
J.toJSON SEDebit {debit = SDBadge} `shouldBe` J.object ["type" J..= ("debit" :: T.Text), "debit" J..= J.object ["type" J..= ("badge" :: T.Text)]]
J.toJSON SEDebit {debit = SDLapse} `shouldBe` J.object ["type" J..= ("debit" :: T.Text), "debit" J..= J.object ["type" J..= ("lapse" :: T.Text)]]
-- a code grant is its own credit type, not a payment whose invoiceId happens to be absent
J.toJSON SECredit {credit = SCCode} `shouldBe` J.object ["type" J..= ("credit" :: T.Text), "credit" J..= J.object ["type" J..= ("code" :: T.Text)]]
J.toJSON SECredit {credit = SCCode} `shouldNotBe` J.toJSON SECredit {credit = SCPayment {invoiceId = Nothing}}
-- an entry type from a newer service is stored and re-emitted unchanged
let futureCredit = J.object ["type" J..= ("grant" :: T.Text), "grantedBy" J..= ("operator" :: T.Text)]
case J.fromJSON futureCredit of
+790 -16
View File
@@ -1,8 +1,12 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Bots.BadgeServiceTests where
@@ -13,23 +17,42 @@ import ChatTests.DBUtils
import ChatTests.Utils
import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Concurrent.STM (atomically, readTMVar)
import Control.Monad (void, when)
import Control.Exception (finally)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import Data.Char (toLower)
import Data.Either (isLeft, isRight)
import Data.Int (Int64)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import qualified Data.Map.Strict as M
import Data.Maybe (isJust, isNothing)
import Data.String (fromString)
import System.Timeout (timeout)
import Data.Text (Text)
import qualified Data.Text as T
import Simplex.Chat.Badges (BadgeType (..))
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay)
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType (..), generateMasterKey)
import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode)
import Simplex.Chat.Controller (ChatConfig (..), ChatController, ChatResponse (CRCustomChatResponse))
import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, endOfMondayAfter)
import Simplex.Chat.Badges.Service
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (CRCustomChatResponse))
import Simplex.Chat.Core (sendChatCmdStr)
import Simplex.Chat.Options (CoreChatOpts (..))
import Simplex.Chat.Options.DB
import Simplex.Messaging.Agent.Store.Common (withTransaction)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Chat.Types (ChatPeerType (..), Profile (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (BBSSecretKey, bbsKeyGen)
import Simplex.Messaging.Encoding.String (strDecode, strEncode, textEncode)
import Simplex.Messaging.Util (safeDecodeUtf8)
import System.FilePath ((</>))
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
#else
import Database.SQLite.Simple (Only (..))
#endif
import Test.Hspec hiding (it)
badgeServiceTests :: SpecWith TestParams
@@ -39,10 +62,28 @@ badgeServiceTests = do
it "should return the same badge when the same code is redeemed twice" testRedeemBadgeCodeTwice
it "should answer code_invalid to an unknown code, indistinguishably from a malformed one" testRedeemUnknownCode
it "should tell a second profile redeeming the same code that it is used" testRedeemSameCodeOtherProfile
it "should redeem a second code, and not restore the first badge on replay" testRedeemSecondCode
it "should refuse a second code while a badge is held, leaving it unspent" testRedeemSecondCode
it "should refuse to issue a code with an unknown badge type or a nonsense month count" testIssueRejectsBadArguments
it "should refuse a request whose purchaseKey is not the verified signer" testPurchaseKeyMismatch
it "should refuse to start unless the issuer secret is the key trusted at its index" testIssuerKeyMustMatchConfig
it "should credit a code's months and issue one credential per month" testCodeMonthsRenew
it "should return the stored credential for a repeat inside an issued period" testRepeatInsideIssuedPeriod
it "should lapse only the months that elapsed while the client was away" testLapseWhileAway
it "should round the last month's expiry up to the end of the Monday after it" testLastMonthExpiryRounds
it "should sign a renewal with the master key stored on the purchase" testRenewalSignsWithStoredMasterKey
it "should leave the client holding the same ledger rows as the service" testClientReplicatesLedger
it "should renew a badge whose credential is lapsing, with no command" testWorkerRenews
it "should request from the wake it set a day before the credential lapses" testRequestWakeFires
it "should present from the wake it set at the credential's expiry" testPresentWakeFires
it "should renew a badge whose newest ledger row is of an unknown type" testRenewsAfterUnknownEntry
it "should catch up the months that lapsed while the client was stopped" testRenewsAfterRestart
it "should stop showing a badge whose balance ran out, and tell contacts" testWorkerRetiresExpired
it "should retire when entitlement ends, not when the credential expires" testRetiresWhenEntitlementEnds
it "should alert that support ended, survive a restart, and go silent once acknowledged" testEndedAlert
it "should raise a snoozed alert once more when the snooze lapses" testSnoozedAlertReturns
it "should renew a badge on a profile that is not active, without switching to it" testRenewalKeepsActiveProfile
it "should broadcast the current profile when a renewal presents a badge" testRenewalKeepsProfileEdits
it "should present the month already issued when a previous pass did not" testPresentationCatchesUp
badgeProfile :: Profile
badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
@@ -74,14 +115,45 @@ mkBadgeServiceOpts TestParams {tmpPath = ps} secretKey =
testing = True
}
-- | A clock the service and the client both read: real time plus an offset the test moves. It
-- tracks real time rather than freezing it, so a sleeper still sleeps the right real duration.
newtype TestClock = TestClock (IORef NominalDiffTime)
newTestClock :: IO TestClock
newTestClock = TestClock <$> newIORef 0
testClockTime :: TestClock -> IO UTCTime
testClockTime (TestClock r) = do
offset <- readIORef r
addUTCTime offset <$> getCurrentTime
-- | Move the clock so that "now" becomes exactly the given time - months are calendar months, so
-- a test crosses a boundary by naming the date rather than adding a duration.
setClockAt :: TestClock -> UTCTime -> IO ()
setClockAt (TestClock r) t = getCurrentTime >>= \real -> writeIORef r (diffUTCTime t real)
-- | Everything a badge test may need from a running service.
data BadgeServiceEnv = BadgeServiceEnv
{ bsIssuerKey :: BadgeIssuerKey,
bsClock :: TestClock,
bsClientCfg :: ChatConfig,
bsAddress :: String,
bsController :: ChatController
}
-- | Start the badge service on a fresh issuer key, and hand the test body what depends on it:
-- the client config trusting that key and addressing the service, the address, and the controller.
withBadgeService :: HasCallStack => TestParams -> (ChatConfig -> String -> ChatController -> IO ()) -> IO ()
withBadgeService ps test = do
withBadgeService ps test =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsAddress, bsController} -> test bsClientCfg bsAddress bsController
withBadgeServiceEnv :: HasCallStack => TestParams -> (BadgeServiceEnv -> IO ()) -> IO ()
withBadgeServiceEnv ps test = do
Right (pk, sk) <- bbsKeyGen
clock <- newTestClock
let opts = mkBadgeServiceOpts ps sk
-- the service refuses to start unless its secret is the key trusted at its index
svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk}
svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk, badgeCurrentTime = testClockTime clock}
withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure ()
-- First start: badge service takes the CreateMyAddress branch.
runBadgeService svcCfg opts $ \_ -> pure ()
@@ -97,7 +169,7 @@ withBadgeService ps test = do
-- Second start: badge service takes the ShowMyAddress branch, then serves the test body.
runBadgeService svcCfg opts $ \env -> do
cc <- atomically $ readTMVar $ serviceCC env
test clientCfg bsLink cc
test BadgeServiceEnv {bsIssuerKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}, bsClock = clock, bsClientCfg = clientCfg, bsAddress = bsLink, bsController = cc}
-- through the operator command the service actually exposes, not the function behind it
issueCode :: HasCallStack => ChatController -> BadgeType -> Int -> IO BadgeCode
@@ -108,11 +180,15 @@ issueCode cc badgeType months =
_ -> error $ "unexpected issue response: " <> T.unpack response
r -> error $ "issue failed: " <> show (() <$ r)
-- | The post-start hook fills serviceCC once the address exists, so waiting on it is the service
-- being ready. A fixed delay here raced with startup and left the address output of one start
-- arriving during the next test.
runBadgeService :: ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO ()
runBadgeService cfg opts action = do
env <- newServiceState
t <- forkIO $ badgeService opts cfg env
threadDelay 500000
ready <- timeout 30000000 $ atomically $ readTMVar $ serviceCC env
when (isNothing ready) $ killThread t >> error "badge service did not start"
action env `finally` killThread t
codeArg :: BadgeCode -> String
@@ -225,8 +301,8 @@ issueRaw cc args =
Right CRCustomChatResponse {} -> pure $ Right ()
_ -> pure $ Left ()
-- Both purchases fund no payment, so both leave payment_id NULL under UNIQUE(payment_id).
-- The first purchase stays as it is: retiring a superseded badge is not implemented.
-- The guard is the badge on the profile, so it refuses whatever the code is and whichever type it
-- funds; nothing is sent, so the refused code is still redeemable.
testRedeemSecondCode :: HasCallStack => TestParams -> IO ()
testRedeemSecondCode ps =
withBadgeService ps $ \clientCfg _ cc ->
@@ -238,16 +314,15 @@ testRedeemSecondCode ps =
alice <## "supporter badge - active"
alice <##. "expires "
alice ##> ("/_redeem_badge_code 1 " <> codeArg legend)
alice <## "bad chat command: badge already active"
alice ##> "/p"
showActiveUser alice "alice (Alice, * supporter)"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> ("/_redeem_badge_code 2 " <> codeArg legend)
alice <## "badge redeemed"
alice <## "legend badge - active"
alice <##. "expires "
alice ##> "/p"
showActiveUser alice "alice (Alice, * legend)"
-- replaying the first code must not put supporter back
alice ##> ("/_redeem_badge_code 1 " <> codeArg supporter)
alice <## "badge already redeemed"
alice ##> "/p"
showActiveUser alice "alice (Alice, * legend)"
-- Each profile stashes its own keys, so the second reaches the service as a different signer -
-- rather than being handed the first profile's badge, or colliding in badge_code_redemptions.
@@ -269,6 +344,705 @@ testRedeemSameCodeOtherProfile ps =
alice ##> "/user alice"
showActiveUser alice "alice (Alice, * supporter)"
-- Ledger behaviour, driven against the real service - real signing, real rows - through the
-- handler rather than the transport, so the typed response can be asserted. The transport and its
-- purchaseKey guard are covered by the tests above.
serviceCmd :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse
serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} purchaseKey request =
badgeServiceResponse bsIssuerKey bsController (Just purchaseKey) reqObject
where
reqObject = case J.toJSON BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} of
J.Object o -> o
_ -> error "badge service request must encode as an object"
-- the fields a ledger assertion reads, without the ambiguity of the shared record names
entryOf :: StatementEntry -> (Int, Int, UTCTime)
entryOf StatementEntry {changeMonths, balanceMonths, balanceStartTs} = (changeMonths, balanceMonths, balanceStartTs)
anchorOf :: StatementEntry -> UTCTime
anchorOf StatementEntry {balanceAnchorTs} = balanceAnchorTs
entryTag :: StatementEntry -> Text
entryTag StatementEntry {entryType} = case entryType of
SECredit c -> creditTypeTag c
SEDebit d -> debitTypeTag d
statementOf :: HasCallStack => BadgeServiceResponse -> ([StatementEntry], Maybe Text)
statementOf = \case
BSPBadgeCredential {statement = BadgeStatement {entries, previousEntryId}} -> (entries, previousEntryId)
r -> error $ "expected badgeCredential, got " <> show (J.toJSON r)
credentialOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeCredential
credentialOf = \case
BSPBadgeCredential {credential} -> credential
r -> error $ "expected badgeCredential, got " <> show (J.toJSON r)
-- the next month falls due when the balance start reaches it, which is the last entry's start
nextDue :: [StatementEntry] -> UTCTime
nextDue entries = let (_, _, start) = entryOf (last entries) in start
newPurchaseKeys :: IO (C.PublicKeyEd25519, BadgeMasterKey)
newPurchaseKeys = do
g <- C.newRandom
(purchaseKey, _) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519)
(purchaseKey,) <$> generateMasterKey g
assertBalance :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> StatementEntry -> IO BadgeServiceResponse
assertBalance env purchaseKey lastEntry =
serviceCmd env purchaseKey BSCIssueBadge {balance = BadgeBalance {lastEntry}}
expiryOf :: HasCallStack => BadgeServiceResponse -> Maybe UTCTime
expiryOf r = (\(BadgeCredential _ _ _ BadgeInfo {badgeExpiry}) -> badgeExpiry) <$> credentialOf r
masterKeyOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeMasterKey
masterKeyOf r = (\(BadgeCredential _ mk _ _) -> mk) <$> credentialOf r
-- A three month code credits three months and issues the first; a month later the second is
-- issued, and only then. Asserted against the service's own rows.
testCodeMonthsRenew :: HasCallStack => TestParams -> IO ()
testCodeMonthsRenew ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do
code <- issueCode cc BTSupporter 3
(purchaseKey, masterKey) <- newPurchaseKeys
redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code}
let (entries, previousEntryId) = statementOf redeemed
previousEntryId `shouldBe` Nothing
map entryTag entries `shouldBe` ["code", "badge"]
map (\e -> let (c, m, _) = entryOf e in (c, m)) entries `shouldBe` [(3, 3), (-1, 2)]
credentialOf redeemed `shouldSatisfy` isJust
let firstDue = nextDue entries
-- still inside the first month: nothing new is written
r2 <- assertBalance env purchaseKey (last entries)
map entryTag (fst $ statementOf r2) `shouldBe` []
-- the second month falls due
setClockAt bsClock firstDue
r3 <- assertBalance env purchaseKey (last entries)
let (entries3, prev3) = statementOf r3
prev3 `shouldBe` Just (entryIdOf $ last entries)
map entryTag entries3 `shouldBe` ["badge"]
map (\e -> let (c, m, _) = entryOf e in (c, m)) entries3 `shouldBe` [(-1, 1)]
credentialOf r3 `shouldSatisfy` isJust
-- a different credential for a different month, not the same signature returned twice
credentialOf r3 `shouldNotBe` credentialOf redeemed
where
entryIdOf StatementEntry {entryId} = entryId
-- A repeat inside an issued month returns the credential already stored, and writes no row:
-- re-signing the same period would churn the client's credential for nothing.
testRepeatInsideIssuedPeriod :: HasCallStack => TestParams -> IO ()
testRepeatInsideIssuedPeriod ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do
code <- issueCode cc BTSupporter 2
(purchaseKey, masterKey) <- newPurchaseKeys
redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code}
let (entries, _) = statementOf redeemed
repeated <- assertBalance env purchaseKey (last entries)
map entryTag (fst $ statementOf repeated) `shouldBe` []
credentialOf repeated `shouldBe` credentialOf redeemed
-- Months that passed unissued are lapsed in one row, and the month now current is issued.
testLapseWhileAway :: HasCallStack => TestParams -> IO ()
testLapseWhileAway ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do
code <- issueCode cc BTSupporter 6
(purchaseKey, masterKey) <- newPurchaseKeys
redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code}
let (entries, _) = statementOf redeemed
-- away past the fourth boundary of the run: three months lapse, the fourth is due. Counted
-- from the anchor - adding months to an already clipped due date would miss the boundary.
setClockAt bsClock (addMonths 4 (anchorOf (last entries)))
away <- assertBalance env purchaseKey (last entries)
let (entries', _) = statementOf away
map entryTag entries' `shouldBe` ["lapse", "badge"]
map (\e -> let (c, m, _) = entryOf e in (c, m)) entries' `shouldBe` [(-3, 2), (-1, 1)]
credentialOf away `shouldSatisfy` isJust
-- Every badge issued in a week expires at the same moment, so the expiry says nothing about when
-- it was bought. On the last month of a balance paidThrough is the period end exactly, so a
-- client-proposed expiry capped the rounding away and put the expiry back on the anniversary.
testLastMonthExpiryRounds :: HasCallStack => TestParams -> IO ()
testLastMonthExpiryRounds ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do
code <- issueCode cc BTSupporter 2
(purchaseKey, masterKey) <- newPurchaseKeys
redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code}
let (entries, _) = statementOf redeemed
setClockAt bsClock (nextDue entries)
renewed <- assertBalance env purchaseKey (last entries)
let (entries', _) = statementOf renewed
map entryTag entries' `shouldBe` ["badge"]
-- the last month the balance funds: nothing is left to issue after it
map (\e -> let (c, m, _) = entryOf e in (c, m)) entries' `shouldBe` [(-1, 0)]
expiryOf renewed `shouldBe` Just (endOfMondayAfter (nextDue entries'))
-- A renewal states nothing, so the credential carries the master key stored with the purchase.
-- The service used to sign whatever key the request supplied, checking only the badge type.
testRenewalSignsWithStoredMasterKey :: HasCallStack => TestParams -> IO ()
testRenewalSignsWithStoredMasterKey ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do
code <- issueCode cc BTSupporter 2
(purchaseKey, masterKey) <- newPurchaseKeys
redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code}
masterKeyOf redeemed `shouldBe` Just masterKey
let (entries, _) = statementOf redeemed
setClockAt bsClock (nextDue entries)
renewed <- assertBalance env purchaseKey (last entries)
masterKeyOf renewed `shouldBe` Just masterKey
-- The replicated columns of a ledger, in order. service_created_at and created_at are left out:
-- the client records when it stored a row, which is not when the service wrote it.
type ReplicatedRow = (Text, Int, Int, UTCTime, Text, Maybe Text)
ledgerRows :: ChatController -> String -> IO [ReplicatedRow]
ledgerRows ChatController {chatStore} table =
withTransaction chatStore $ \db ->
DB.query_ db . fromString $
"SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, "
<> "COALESCE(entry_credit_type, entry_debit_type) FROM "
<> table
<> " ORDER BY entry_id"
-- The client copies the statement verbatim and authors nothing, so after a redemption both sides
-- hold the same rows under the same entry ids.
testClientReplicatesLedger :: HasCallStack => TestParams -> IO ()
testClientReplicatesLedger ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 3
alice ##> ("/_redeem_badge_code 1 " <> codeArg code)
alice <## "badge redeemed"
alice <## "supporter badge - active"
alice <##. "expires "
serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger"
clientLedger <- ledgerRows (chatController alice) "badge_ledger"
-- the code credit and the first month, on both sides
map (\(_, ch, m, _, _, t) -> (ch, m, t)) serviceLedger `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge")]
clientLedger `shouldBe` serviceLedger
-- redeeming again replays the statement, and must not duplicate a single row
alice ##> ("/_redeem_badge_code 1 " <> codeArg code)
alice <## "badge already redeemed"
clientLedger' <- ledgerRows (chatController alice) "badge_ledger"
clientLedger' `shouldBe` serviceLedger
-- nor a second issuance for the one month issued: the replay names a month already stored
expiries <- issuedExpiries (chatController alice)
length expiries `shouldBe` 1
-- the balance start of the last row, which is when the next month falls due
dueAtOf :: [ReplicatedRow] -> UTCTime
dueAtOf rows = let (_, _, _, start, _, _) = last rows in start
-- | The two clock positions a renewal needs: the request, a day before the shown credential
-- lapses, and the presentation, as it lapses.
renewalMoments :: [ReplicatedRow] -> (UTCTime, UTCTime)
renewalMoments rows =
let expiry = endOfMondayAfter $ dueAtOf rows
in (addUTCTime (-nominalDay) expiry, expiry)
-- | Real seconds between arming a wake and it firing. Long enough for the arming pass to finish
-- first, since one that overruns does the work itself and the test passes without a wake at all.
badgeWakeMargin :: NominalDiffTime
badgeWakeMargin = 3
-- | Stand the clock just short of t and signal once. A sleeping worker cannot see the clock move,
-- so the signal is what makes it re-derive - and, standing short, it arms the wake at t rather
-- than doing the work. Whatever follows is produced by that wake.
armWakeAt :: HasCallStack => TestCC -> TestClock -> UTCTime -> IO ()
armWakeAt cc clock t = do
setClockAt clock $ addUTCTime (negate badgeWakeMargin) t
cc ##> "/_app activate"
cc <## "ok"
issuedExpiries :: ChatController -> IO [UTCTime]
issuedExpiries ChatController {chatStore} = do
rows :: [(UTCTime, Int64)] <-
withTransaction chatStore $ \db ->
DB.query_ db "SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end"
pure $ map fst rows
peerBadgeExpiry :: ChatController -> IO (Maybe UTCTime)
peerBadgeExpiry ChatController {chatStore} = do
rows :: [(Maybe UTCTime, Int64)] <-
withTransaction chatStore $ \db ->
DB.query_ db "SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_proof IS NOT NULL ORDER BY contact_profile_id"
pure $ case rows of
((t, _) : _) -> t
[] -> Nothing
setBadgeExpiry :: ChatController -> String -> UTCTime -> IO ()
setBadgeExpiry ChatController {chatStore} whichBadge t =
withTransaction chatStore $ \db ->
DB.execute db (fromString $ "UPDATE contact_profiles SET badge_expiry = ? WHERE " <> whichBadge <> " IS NOT NULL") (Only t)
-- | Copy the newest ledger row as an entry of a type this version does not know: the service is
-- deployed ahead of app releases, so a new type first arrives at clients that predate it.
insertUnknownLedgerEntry :: ChatController -> IO ()
insertUnknownLedgerEntry ChatController {chatStore} =
withTransaction chatStore $ \db ->
DB.execute_ db . fromString $
"INSERT INTO badge_ledger"
<> " (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts,"
<> " balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type,"
<> " entry_type_unknown, entry_type_value)"
<> " SELECT 'unknown-entry', badge_purchase_id, 0, balance_months, balance_start_ts, balance_anchor_ts,"
<> " balance_badge_type, service_created_at, created_at, 'credit', 'grant', 1, '{\"type\":\"grant\"}'"
<> " FROM badge_ledger ORDER BY entry_id DESC LIMIT 1"
-- | The month the profile is showing, and the month last issued. They must agree: a profile left
-- on an earlier month shows contacts a badge the ledger has already replaced.
shownAndIssuedExpiry :: ChatController -> IO (Maybe UTCTime, Maybe UTCTime)
shownAndIssuedExpiry ChatController {chatStore} = withTransaction chatStore $ \db -> do
shown :: [(Maybe UTCTime, Int64)] <-
DB.query_ db "SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_signature IS NOT NULL ORDER BY contact_profile_id"
issued :: [(Maybe UTCTime, Int64)] <-
DB.query_ db "SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end DESC LIMIT 1"
pure (firstOf shown, firstOf issued)
where
firstOf = \case
((t, _) : _) -> t
[] -> Nothing
waitShownIssued :: HasCallStack => ChatController -> IO ()
waitShownIssued cc = loop (100 :: Int)
where
-- both absent would compare equal, so the presence of a badge is asserted separately
loop 0 = shownAndIssuedExpiry cc >>= \(shown, issued) -> do
shown `shouldSatisfy` isJust
shown `shouldBe` issued
loop i =
shownAndIssuedExpiry cc >>= \(shown, issued) ->
if isJust shown && shown == issued then pure () else threadDelay 50000 >> loop (i - 1)
-- The worker acts on its own schedule, so the test waits for the rows rather than for a response.
waitLedgerRows :: HasCallStack => ChatController -> Int -> IO [ReplicatedRow]
waitLedgerRows cc n = loop (100 :: Int)
where
loop 0 = ledgerRows cc "badge_ledger" >>= \rows -> error $ "expected " <> show n <> " ledger rows, got " <> show (length rows)
loop i = do
rows <- ledgerRows cc "badge_ledger"
if length rows >= n then pure rows else threadDelay 50000 >> loop (i - 1)
-- the badge the profile shows, which the worker clears when the balance has run out
shownBadgeId :: HasCallStack => ChatController -> IO (Maybe Int64)
shownBadgeId ChatController {chatStore} = do
-- two columns rather than one, so the row type needs no backend-specific Only
rows :: [(Maybe Int64, Int64)] <-
withTransaction chatStore $ \db ->
DB.query_ db "SELECT shown_badge_id, user_id FROM users WHERE user_id = 1"
-- not Nothing on an unexpected shape: waiting for Nothing would then pass without reading it
pure $ case rows of
[(i, _)] -> i
_ -> error $ "expected one users row, got " <> show rows
-- the occurrence the user answered, which silences that alert and no other
ackedEpisode :: HasCallStack => ChatController -> IO (Maybe Text, Maybe Text)
ackedEpisode ChatController {chatStore} = do
rows :: [(Maybe Text, Maybe Text)] <-
withTransaction chatStore $ \db ->
DB.query_ db "SELECT alert_acked_kind, alert_acked_episode FROM badge_purchases"
pure $ case rows of
[r] -> r
_ -> error $ "expected one badge purchase, got " <> show rows
waitShownBadge :: HasCallStack => ChatController -> Maybe Int64 -> IO ()
waitShownBadge cc expected = loop (100 :: Int)
where
loop 0 = shownBadgeId cc >>= \actual -> actual `shouldBe` expected
loop i =
shownBadgeId cc >>= \actual ->
if actual == expected then pure () else threadDelay 50000 >> loop (i - 1)
redeemFirstBadge :: HasCallStack => TestCC -> BadgeCode -> IO ()
redeemFirstBadge alice code = do
alice ##> ("/_redeem_badge_code 1 " <> codeArg code)
alice <## "badge redeemed"
alice <## "supporter badge - active"
alice <##. "expires "
-- A credential nears its expiry and the badge renews from the worker's own pass, with no command
-- sent by the app: chat activate only signals it, and the work is derived from stored state.
-- The request and the presentation are a day apart, so each renewal takes two passes.
testWorkerRenews :: HasCallStack => TestParams -> IO ()
testWorkerRenews ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 3
redeemFirstBadge alice code
redeemed <- ledgerRows (chatController alice) "badge_ledger"
map (\(_, ch, m, _, _, t) -> (ch, m, t)) redeemed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge")]
-- the credential the profile shows is a day from lapsing, while the app is running
let (requestAt, presentAt) = renewalMoments redeemed
setClockAt bsClock requestAt
alice ##> "/_app activate"
alice <## "ok"
renewed <- waitLedgerRows (chatController alice) 3
-- the renewal reports itself, no command having asked for it
alice <##. "1: supporter"
map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")]
-- the month is issued but not yet worn: the profile still carries the one it had
(shownEarly, issuedEarly) <- shownAndIssuedExpiry (chatController alice)
shownEarly `shouldNotBe` issuedEarly
-- a day later the held credential lapses and the new month is presented
setClockAt bsClock presentAt
alice ##> "/_app activate"
alice <## "ok"
waitShownIssued (chatController alice)
-- the third month too: the state a renewal leaves must support the next one
let (requestAt2, presentAt2) = renewalMoments renewed
setClockAt bsClock requestAt2
alice ##> "/_app activate"
alice <## "ok"
alice <##. "1: supporter"
renewed2 <- waitLedgerRows (chatController alice) 4
map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed2
`shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge"), (-1, 0, Just "badge")]
-- the client authored none of them: the service holds exactly the same rows
serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger"
renewed2 `shouldBe` serviceLedger
-- and the profile shows the month last issued, not an earlier one
setClockAt bsClock presentAt2
alice ##> "/_app activate"
alice <## "ok"
waitShownIssued (chatController alice)
-- The request is made by the wake the worker set for itself a day before the credential lapses.
testRequestWakeFires :: HasCallStack => TestParams -> IO ()
testRequestWakeFires ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 3
redeemFirstBadge alice code
redeemed <- ledgerRows (chatController alice) "badge_ledger"
armWakeAt alice bsClock $ fst $ renewalMoments redeemed
-- the arming pass asked for nothing, so what follows cannot be its doing
ledgerRows (chatController alice) "badge_ledger" >>= (`shouldBe` redeemed)
renewed <- waitLedgerRows (chatController alice) 3
alice <##. "1: supporter"
map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed
`shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")]
-- The presentation is made by the wake at the expiry itself, a day after the request that issued
-- the month it presents.
testPresentWakeFires :: HasCallStack => TestParams -> IO ()
testPresentWakeFires ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 3
redeemFirstBadge alice code
redeemed <- ledgerRows (chatController alice) "badge_ledger"
let (requestAt, presentAt) = renewalMoments redeemed
setClockAt bsClock requestAt
alice ##> "/_app activate"
alice <## "ok"
void $ waitLedgerRows (chatController alice) 3
alice <##. "1: supporter"
armWakeAt alice bsClock presentAt
-- the arming pass presented nothing: the profile still carries the credential it had
shownAndIssuedExpiry (chatController alice) >>= \(shown, issued) -> shown `shouldNotBe` issued
waitShownIssued (chatController alice)
-- The newest row being of an unknown type must not stop the renewal: it is stored verbatim and
-- read back to be asserted, rather than leaving the client with no entry to assert at all.
testRenewsAfterUnknownEntry :: HasCallStack => TestParams -> IO ()
testRenewsAfterUnknownEntry ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 3
redeemFirstBadge alice code
rows <- ledgerRows (chatController alice) "badge_ledger"
insertUnknownLedgerEntry (chatController alice)
setClockAt bsClock $ fst $ renewalMoments rows
alice ##> "/_app activate"
alice <## "ok"
renewed <- waitLedgerRows (chatController alice) 4
alice <##. "1: supporter"
-- the unknown row is asserted, so the service answers with what it does not hold: the whole
-- ledger, which re-stores the two rows already held and adds the month it issued
map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed
`shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (0, 2, Just "grant"), (-1, 1, Just "badge")]
-- The worker driven by chat start rather than by activate, and the only test where the client is
-- given a lapse row to store: the months that passed while the app was stopped.
testRenewsAfterRestart :: HasCallStack => TestParams -> IO ()
testRenewsAfterRestart ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> do
rows <- withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 6
redeemFirstBadge alice code
ledgerRows (chatController alice) "badge_ledger"
-- the credit row's start is the anchor: a grant on a run that has never lapsed moves neither
let (_, _, _, anchor, _, _) = head rows
-- stopped until past the fourth boundary of the run: three months lapse, the fourth is issued
setClockAt bsClock $ addMonths 4 anchor
withTestChatCfg ps bsClientCfg "alice" $ \alice -> do
renewed <- waitLedgerRows (chatController alice) 4
alice <##. "1: supporter"
map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed
`shouldBe` [(6, 6, Just "code"), (-1, 5, Just "badge"), (-3, 2, Just "lapse"), (-1, 1, Just "badge")]
-- the lapse row was replicated rather than authored here
serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger"
renewed `shouldBe` serviceLedger
-- a week missed costs only the day between the two steps: one pass does both
waitShownIssued (chatController alice)
-- When the balance is spent and the last period ends, the badge stops being shown and the profile
-- update reaches contacts - the visible half of "the badge expired".
testWorkerRetiresExpired :: HasCallStack => TestParams -> IO ()
testWorkerRetiresExpired ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice ->
withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do
connectUsers alice bob
code <- issueCode cc BTSupporter 1
redeemFirstBadge alice code
alice #> "@bob hi"
bob <# "alice *> hi"
-- bob sees it before it expires
bob ##> "/i alice"
bob <## "contact ID: 2"
bob <## "supporter badge - active"
bob <##. "expires "
bob <## "receiving messages via: localhost"
bob <## "sending messages via: localhost"
bob <## "you've shared main profile with this contact"
bob <## "connection not verified, use /code command to see security code"
bob <## "quantum resistant end-to-end encryption"
bob <## currentChatVRangeInfo
rows <- ledgerRows (chatController alice) "badge_ledger"
-- the one month it bought has ended and nothing is left to issue
setClockAt bsClock $ dueAtOf rows
alice ##> "/_app activate"
alice <## "ok"
-- support ended, and the state it changed by retiring the badge carries the same alert
alice <##. "badge alert: support_ended "
alice <##. "1: supporter"
alice <##. "badge alert: support_ended "
-- the profile stops showing it locally
waitShownBadge (chatController alice) Nothing
alice ##> "/p"
alice <## "user profile: alice (Alice)"
alice <## "use /p <name> [<bio>] to change it"
-- The removal travels as a profile update, which prints nothing when only the badge
-- changed (viewContactUpdated compares names and links). The next message shows it
-- arrived: bob's prefix loses the badge marker it carried above.
alice #> "@bob after"
bob <# "alice> after"
-- and the badge is gone from the contact's stored profile, not merely from the prefix
bob ##> "/i alice"
bob <## "contact ID: 2"
bob <## "receiving messages via: localhost"
bob <## "sending messages via: localhost"
bob <## "you've shared main profile with this contact"
bob <## "connection not verified, use /code command to see security code"
bob <## "quantum resistant end-to-end encryption"
bob <## currentChatVRangeInfo
-- The credential outlives the balance by up to eight days, because its expiry covers renewal and
-- not entitlement. A worker scheduled only on that expiry would leave the badge worn, and the user
-- unasked to buy again, for the whole of that week - so paidThrough is a wake of its own.
testRetiresWhenEntitlementEnds :: HasCallStack => TestParams -> IO ()
testRetiresWhenEntitlementEnds ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 1
redeemFirstBadge alice code
rows <- ledgerRows (chatController alice) "badge_ledger"
armWakeAt alice bsClock $ dueAtOf rows
-- that pass retired nothing and printed nothing: the badge is still worn and /p says so
alice ##> "/p"
alice <## "user profile: alice (Alice, * supporter)"
alice <## "use /p <name> [<bio>] to change it"
-- nothing signals the worker from here, and the credential has days left, so the wake that
-- produces these is the one the worker set for itself at paidThrough
alice <##. "badge alert: support_ended "
alice <##. "1: supporter"
alice <##. "badge alert: support_ended "
waitShownBadge (chatController alice) Nothing
-- The alert is derived from stored state rather than kept pending, so it is still there after a
-- restart; acknowledging records the occurrence it answered, and the same one is not raised again.
testEndedAlert :: HasCallStack => TestParams -> IO ()
testEndedAlert ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> do
endsAt <- withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 1
redeemFirstBadge alice code
rows <- ledgerRows (chatController alice) "badge_ledger"
let endsAt = dueAtOf rows
setClockAt bsClock endsAt
alice ##> "/_app activate"
alice <## "ok"
-- the pass raises the alert, and reports the state it changed by retiring the badge - which
-- carries the same alert, the alert being part of the state
alice <##. "badge alert: support_ended "
alice <##. "1: supporter"
alice <##. "badge alert: support_ended "
pure endsAt
-- nothing was stored as pending, and the alert is derived again on the next start
withTestChatCfg ps bsClientCfg "alice" $ \alice -> do
alice <##. "badge alert: support_ended "
alice ##> ("/_badge ack 1 1 support_ended off " <> T.unpack (safeDecodeUtf8 $ strEncode endsAt))
alice <##. "1: supporter"
ackedEpisode (chatController alice) `shouldReturn` (Just "support_ended", Just (safeDecodeUtf8 $ strEncode endsAt))
-- acknowledged: the state no longer carries the alert, and no event raises it again
alice ##> "/_badge state 1"
alice <##. "1: supporter"
alice ##> "/_app activate"
alice <## "ok"
alice ##> "/p"
alice <## "user profile: alice (Alice)"
alice <## "use /p <name> [<bio>] to change it"
-- A worker runs for every profile, not only the one in use, so presenting a renewed badge must not
-- make its profile active - the next message would then be sent from the wrong identity.
testRenewalKeepsActiveProfile :: HasCallStack => TestParams -> IO ()
testRenewalKeepsActiveProfile ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 3
redeemFirstBadge alice code
rows <- ledgerRows (chatController alice) "badge_ledger"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
-- alice's credential is a day from lapsing while alisa is the profile in use
let (requestAt, presentAt) = renewalMoments rows
setClockAt bsClock requestAt
alice ##> "/_app activate"
alice <## "ok"
renewed <- waitLedgerRows (chatController alice) 3
map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed
`shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")]
-- the renewal is reported for alice, and the prefix is there because alice is not active
alice <##. "[user: alice] 1: supporter"
-- presenting is the pass that writes alice's profile, and it is the one that could switch
setClockAt bsClock presentAt
alice ##> "/_app activate"
alice <## "ok"
waitShownIssued (chatController alice)
alice ##> "/p"
showActiveUser alice "alisa"
-- A snooze silences the alert until it lapses, and then it is raised once more, without a restart.
-- A snooze changes nothing else on the purchase, so the occurrence already raised has to count it.
testSnoozedAlertReturns :: HasCallStack => TestParams -> IO ()
testSnoozedAlertReturns ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
code <- issueCode cc BTSupporter 1
redeemFirstBadge alice code
rows <- ledgerRows (chatController alice) "badge_ledger"
let endsAt = dueAtOf rows
setClockAt bsClock endsAt
alice ##> "/_app activate"
alice <## "ok"
alice <##. "badge alert: support_ended "
alice <##. "1: supporter"
alice <##. "badge alert: support_ended "
alice ##> ("/_badge ack 1 1 support_ended on " <> T.unpack (safeDecodeUtf8 $ strEncode endsAt))
alice <##. "1: supporter"
-- the ack signalled the worker, and the pass it ran was silent: /p prints only its own output
alice ##> "/p"
alice <## "user profile: alice (Alice)"
alice <## "use /p <name> [<bio>] to change it"
-- the snooze lapses: nothing else about the badge changed, so only the alert is reported
setClockAt bsClock $ addUTCTime (nominalDay + 60) endsAt
alice ##> "/_app activate"
alice <## "ok"
alice <##. "badge alert: support_ended "
-- The worker re-reads the profile each pass. Presenting a renewed badge from a copy captured when
-- the worker started would revert any edit made since and broadcast the profile in its old form.
testRenewalKeepsProfileEdits :: HasCallStack => TestParams -> IO ()
testRenewalKeepsProfileEdits ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice ->
withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do
connectUsers alice bob
code <- issueCode cc BTSupporter 3
redeemFirstBadge alice code
-- the profile is edited after the worker started
alice ##> "/p alice Alice Jones"
concurrentlyN_
[ alice <## "user bio changed to Alice Jones (your 1 contacts are notified)",
bob <## "contact alice updated bio: Alice Jones"
]
rows <- ledgerRows (chatController alice) "badge_ledger"
let (requestAt, presentAt) = renewalMoments rows
setClockAt bsClock requestAt
alice ##> "/_app activate"
alice <## "ok"
alice <##. "1: supporter"
renewed <- waitLedgerRows (chatController alice) 3
map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed
`shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")]
-- presenting a day later is the pass that broadcasts the profile
setClockAt bsClock presentAt
alice ##> "/_app activate"
alice <## "ok"
waitShownIssued (chatController alice)
-- The renewal's profile update carries the edited bio. Had it carried the profile the
-- worker started with, bob would print a bio change back to "Alice" here, before the
-- message - so the message arriving next is the assertion.
alice #> "@bob after renewal"
bob <# "alice *> after renewal"
bob ##> "/i alice"
bob <## "contact ID: 2"
bob <## "supporter badge - active"
bob <##. "expires "
bob <## "receiving messages via: localhost"
bob <## "sending messages via: localhost"
bob <## "you've shared main profile with this contact"
bob <## "connection not verified, use /code command to see security code"
bob <## "quantum resistant end-to-end encryption"
bob <## currentChatVRangeInfo
-- Forces the state a crash between the issuance write and the profile write leaves behind: the
-- month is issued, unpresented, and no later pass finds it due.
testPresentationCatchesUp :: HasCallStack => TestParams -> IO ()
testPresentationCatchesUp ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice ->
withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do
connectUsers alice bob
code <- issueCode cc BTSupporter 3
redeemFirstBadge alice code
alice #> "@bob hi"
bob <# "alice *> hi"
rows <- ledgerRows (chatController alice) "badge_ledger"
let (requestAt, presentAt) = renewalMoments rows
setClockAt bsClock requestAt
alice ##> "/_app activate"
alice <## "ok"
alice <##. "1: supporter"
void $ waitLedgerRows (chatController alice) 3
setClockAt bsClock presentAt
alice ##> "/_app activate"
alice <## "ok"
waitShownIssued (chatController alice)
expiries <- issuedExpiries (chatController alice)
length expiries `shouldBe` 2
let firstMonth = head expiries
latestMonth = last expiries
-- the renewal's rows are kept; only its presentation is undone, on both sides
setBadgeExpiry (chatController alice) "badge_signature" firstMonth
setBadgeExpiry (chatController bob) "badge_proof" firstMonth
alice ##> "/_app activate"
alice <## "ok"
waitPeerBadgeExpiry (chatController bob) latestMonth
(shown, issued) <- shownAndIssuedExpiry (chatController alice)
shown `shouldBe` Just latestMonth
issued `shouldBe` Just latestMonth
alice #> "@bob after repair"
bob <# "alice *> after repair"
waitPeerBadgeExpiry :: HasCallStack => ChatController -> UTCTime -> IO ()
waitPeerBadgeExpiry cc expected = loop (100 :: Int)
where
loop 0 = peerBadgeExpiry cc >>= \actual -> actual `shouldBe` Just expected
loop i =
peerBadgeExpiry cc >>= \actual ->
if actual == Just expected then pure () else threadDelay 50000 >> loop (i - 1)
testPurchaseKeyMismatch :: HasCallStack => TestParams -> IO ()
testPurchaseKeyMismatch ps =
withBadgeService ps $ \clientCfg bsLink _ ->