badges: webapp feature branch (#7548)

* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-25 09:01:51 +00:00
1 parent 64014cd8b0
commit 19e70faeec
165 files changed
+33352 -313

No files matched your search

+4 -1
View File
@@ -4,10 +4,13 @@ module Main where
import BadgeService.Options (BadgeServiceOpts (..))
import BadgeService.Service
import Control.Logger.Simple (LogConfig (..), LogLevel (..), setLogLevel, withGlobalLogging)
import Simplex.Chat.Terminal (terminalChatConfig)
-- | withGlobalLogging installs the SMP agent's log sinks, which the chat core otherwise installs only under --log-agent.
main :: IO ()
main = do
main = withGlobalLogging LogConfig {lc_file = Nothing, lc_stderr = True} $ do
setLogLevel LogWarn
opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts
if runCLI
then badgeServiceCLI opts
+194 -6
View File
@@ -1,16 +1,34 @@
# SimpleX badge service
Scaffolding for the SimpleX supporter-badge RPC service. The wire protocol is specified in [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md), and the implementation plans live under [`plans/`](../../plans) (`2026-07-30-supporter-badges-v3-ux.md`, `2026-07-31-badges-core-implementation.md`, `2026-08-04-badges-mvp-scope.md`).
`simplex-badge-service` runs the whole supporter-badge service as one process, two
lanes: a SimpleX chat bot that answers service RPC over a double-ratchet contact
address, and — only when `--service-config` names a `badge_service.ini` — the badge-codes
web checkout (BTCPay and Stripe payment, code issuance, the poller). **Without
`--service-config` the web listener does not start at all**; the process still runs the
chat side and nothing else.
The wire protocol for the RPC side is
[`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md). The web checkout is
specified in
[`plans/badges-codes/2026-08-27-badge-codes.md`](../../plans/badges-codes/2026-08-27-badge-codes.md),
whose §9 is the `badge_service.ini` reference, with the implementation plan in
[`plans/badges-codes/2026-08-31-service-btcpay.md`](../../plans/badges-codes/2026-08-31-service-btcpay.md).
Earlier plans (`2026-07-30-supporter-badges-v3-ux.md`, `2026-07-31-badges-core-implementation.md`,
`2026-08-04-badges-mvp-scope.md`) predate the web checkout and describe the RPC-only
scaffold this service started as.
At this stage the service:
- creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)),
- listens for service requests (`CEvtServiceRequest`) on that address, rejects a request whose `purchaseKey` is not the key the agent verified the signature against, and answers `redeemBadgeCode`,
- issues redemption codes, storing only their `SHA-256` and printing each code once,
- does not accept contact requests — the address is for RPC only,
- does not accept contact requests unless `[dev] chat_redeem` is on: the address is for RPC only,
- in service mode with `--service-config`, also serves the built web app (`npm run build` in `web/`), `POST /api/invoice` and `GET /api/invoice/:id`, the BTCPay and Stripe webhook routes, and a payment poller, seeding its price/offer catalog on every start,
- owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`).
Every other command still answers `unsupported_version`. Ledger writes, invoices and provider webhooks are left for follow-up per the plans; a redemption issues one credential and reports an empty statement.
Every other command answers `unsupported_version`, or `unknown_purchase_key` when the key that
signed it is not one the service has stored — every command but `redeemBadgeCode` needs a
purchase that already exists.
## Build
@@ -27,9 +45,11 @@ simplex-badge-service --help
```
- default (no `--run-cli`): background service mode, no interactive terminal.
- `--run-cli`: interactive CLI that also processes service requests (mirrors `simplex-directory-service --run-cli`).
- `--run-cli`: interactive CLI that also processes service requests (mirrors
`simplex-directory-service --run-cli`). This mode is the chat/RPC side and the `//` commands
below: it starts no web listener and no poller, and `[dev] chat_redeem` does not apply to it,
whatever `--service-config` says.
- `--no-address`: skip address creation on start-up (for operators who provision the address themselves).
The service cannot sign credentials without an issuer key and refuses to start without one:
- `--issuer-key-idx IDX` — the index the apps find the matching public key under (`badgePublicKeys` in `ChatConfig`).
@@ -39,6 +59,163 @@ The service checks the secret against the configured public key at that index an
if they disagree: credentials signed with the wrong key cannot be verified by any client, and the
codes redeemed against them would be spent for nothing.
The key can come from `badge_service.ini` instead:
```ini
[issuer]
index = 1
private_key = <secret from `simplex-chat badge keygen`>
```
`index` is the index clients verify against (`badgePublicKeys` in `ChatConfig`), and `private_key`
is the secret that signs. Both are required, and the key is checked at startup like the flags.
Rotating is a change to both and a restart, once clients trust the new index.
The command line wins over the file when both `--issuer-key-idx` and `--issuer-secret` are given.
Note that a secret passed as a flag is visible to every user on the machine through `ps`, where one
in the ini is only as readable as the file: `badge_service.ini` is gitignored and already holds the
provider API keys and webhook secrets.
Other options:
- `--service-config INI_FILE`: path to `badge_service.ini`. Omit it to run the chat/RPC side
only; the process never starts a web listener without it, and never starts one under
`--run-cli`, which parses and validates the whole file (`[listener] static_dir` included) but
uses only its `[issuer]` section. An issuer key is still required either way.
- `--service-name NAME`: the bot's display name, without `*`s or spaces (default `SimpleX Badges`).
- `--client-service`: use the client service certificate.
- also accepts the standard SimpleX Chat core options — database path, SMP/XFTP servers,
`--socks-proxy`, `--log-level`/`-l`, and the rest — run `simplex-badge-service --help`
for the complete list.
### Running the web checkout
`badge_service.ini` holds the listener bind address and `static_dir`, an optional
`[btcpay]` section (omitting it disables Bitcoin and Monero), an optional `[stripe]`
section (omitting it disables card payments) and the poll cadence.
`badge_service.ini.example` is the committed template; `badge_service.ini` itself is
gitignored, since a real one holds API keys and webhook secrets.
The full walkthrough is in
[`web/README.md`](web/README.md#running-the-real-service-against-this-build). Short version:
```
cd apps/simplex-badge-service/web && npm install && npm run build && cd ../../..
cp apps/simplex-badge-service/badge_service.ini.example apps/simplex-badge-service/badge_service.ini
cabal run simplex-badge-service -- \
--issuer-key-idx IDX --issuer-secret SECRET \
--service-config apps/simplex-badge-service/badge_service.ini
```
`IDX` and `SECRET` must be one of the issuer keys clients already ship, not a fresh
`simplex-chat badge keygen` pair: startup refuses a key no client could verify against.
`trust_forwarded_for` decides what the rate limiter counts. Behind a reverse proxy it must be
`on`, or every request keys on the proxy's address and the whole service shares one bucket of 60
reads and 5 checkouts a minute. Where the listener is reached directly it must be `off`, because
then the header is whatever the caller wrote. The last entry is the one read, which is the one a
proxy appends.
The BTCPay API key needs four permissions, each scoped to the one store:
`cancreateinvoice` and `canviewinvoices` for checkout and the poller,
`canviewstoresettings` to log the store's live payment methods at startup, and
`canmodifyinvoices` so `POST /api/invoice/:id/cancel` can invalidate an invoice at BTCPay
rather than only in this store.
### Card payments (Stripe)
An optional `[stripe]` section enables the card lane; omitting it disables card payments
and `POST /api/invoice` answers `provider_unavailable` for a card order, the same as an
absent `[btcpay]` does for Bitcoin and Monero. Its keys:
- `secret_key` — a **restricted** key (`rk_...`), never a full secret key (`sk_...`). Grant it
**PaymentIntents: write**, to create and cancel intents, plus **Charges: read** — the poller
reads an intent with `expand[]=latest_charge`, and Stripe rejects the whole read if the key
lacks read on an expanded object (settlement then falls back to the slower list pass).
- `publishable_key` (`pk_...`) — the public key the browser mounts the Payment Element with.
- `webhook_secret` (`whsec_...`) — the signing secret of the `/webhooks/stripe` endpoint,
configured in the Stripe Dashboard alongside it.
- `session_minutes` — how many minutes an unpaid card order stays open (1 to 1440, default 60).
Ten minutes after that, the service cancels the payment at Stripe, and retries if that fails.
If the order is over 72 hours old and the cancel has kept failing for an hour, the service marks
it expired and logs an error. Check such orders in the Stripe Dashboard. After a restart, the
hour starts again.
The service fills `publishable_key` into the shell at boot: the built `index.html` ships
with an empty `<meta id="stripe-publishable-key" name="stripe-publishable-key" content="">`
(`web/public/index.html`), and the listener serves a copy with the configured key
substituted in, so the ini is the only place the key is set. With no `[stripe]` section the
pristine (empty) shell is served and the page shows its development stand-in card form. The
dev mock (`web/mock/server.py`) does the same substitution from `$STRIPE_PUBLISHABLE_KEY`,
since it runs without an ini.
`POST /webhooks/stripe` verifies `Stripe-Signature` against `webhook_secret` and queues a
read, the same hint-only role as `POST /webhooks/btcpay`: the poller carries authority, so
an unverified or unreadable delivery costs nothing but a log line. A delivery signed more than
15 minutes away from the server's clock is refused with a warning, so keep the clock synced (NTP).
The reverse proxy in front of this service must send a Content-Security-Policy that
allows Stripe.js and its iframes, since Stripe forbids bundling or self-hosting its
script:
```
script-src 'self' https://js.stripe.com https://*.js.stripe.com;
frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com
```
**Stripe Link must be disabled in the Dashboard.** Left on, it needs `link.com` in the
CSP above and reintroduces an email prompt of its own, which this design otherwise avoids.
**Enable card only; do not enable redirect-based methods** (iDEAL, Bancontact, PayPal, and
the rest) in the Dashboard. The embedded checkout sends no `return_url`, which Stripe requires
the moment a redirect-based method is offered, so enabling one makes every checkout fail to
create. The service pins the Stripe API version it was built against, so the account's default
version does not affect the card lane.
### The checkout endpoints
The browser in `web/` is the only client of the `/api` routes; the two webhooks below are the
providers', one each for BTCPay and Stripe.
The invoice id is the only credential for reading or cancelling an order: it is 16 random bytes,
it travels in the path, and anything that logs request paths logs it. It is also what the buyer
is shown as their reference and asked to quote, so support sees it: losing it lets someone cancel
an invoice, which is why the code, which lets them take the badge, is never printed until the
service says the invoice is paid. The service keeps it out of its own logs, which name the
provider's reference instead.
| Route | Answers |
|---|---|
| `POST /api/invoice` | `{invoiceId, badgeType, months, amount, currency, expiresAt}` plus a destination: `clientSecret` for a card, or `address`, `cryptoAmount`, `cryptoCurrency`. Refuses with `code_conflict`, `catalog_changed`, `bad_request`, `provider_unavailable` or `rate_limited`. |
| `GET /api/invoice/:id` | `{status, badgeType, months, amount, currency, expiresAt}` and the same destination — no `invoiceId`, since the caller already has it — plus `amountPaid`, `cryptoAmountPaid`, `cryptoAmountDue`, `paidInFull`, `settledAt` and `requiredConfirmations` once each has a value. Never the code, which this service has never seen. |
| `GET /api/invoice/:id?wait=<status>&seenPaid=<figure>&seenFull=<0\|1>` | The same, held for up to 30 seconds while the invoice's status is still `<status>` **and** its payment is the one the caller says it has rendered. `wait=paid` and any value that is not a status answer at once, since neither can change. A status the caller has not seen, or a payment it has not seen, answers at once — the provider's verdict counts as much as the figure, because Monero reports an invoice as confirming while its figures are still zero. A request that omits `seenPaid` holds on the status alone. |
| `POST /api/invoice/:id/cancel` | Invalidates an open invoice at the provider and expires it here. Refuses a settled or expired one with `not_open`, and one that already holds a payment with `funded`. The provider is told first, and a payment landing in between does not keep the invoice open: nothing can reach that address any more, so the row is expired either way and the poller settles or reports what arrived. |
| `POST /webhooks/btcpay` | Verifies `BTCPay-Sig` over the bytes as received and queues a read. A hint only: the poller is what carries authority, so an unverified or unreadable delivery costs nothing but a log line. |
| `POST /webhooks/stripe` | Verifies `Stripe-Signature` over the bytes as received and queues a read. The card lane's equivalent of the row above, and a hint just the same: the poller carries authority, so an unverified or unreadable delivery costs nothing but a log line. |
Every `/api` refusal is `{"error": "<code>"}`. Besides the codes above, any of them can answer
`internal`, an unknown id answers `not_found`, and a wrong verb answers `method_not_allowed` —
ten codes in all, which is what the browser's `WIRE_ERROR_CODES` lists. The two webhook routes are
the exception: each answers 200, 400 or 413 with an empty body, because its provider is the only
caller and nothing it could read would change what the route does. A wrong verb on any route, those
two included, answers `method_not_allowed`.
### Redeeming over chat, for local testing
```ini
[dev]
chat_redeem = on
```
With this on, the service accepts contact requests and answers `/redeem <code>` from a contact
with the credential as one-line JSON, ready to paste into a client as `/badge add <json>`. Off by
default, and only `on`/`off` parse, so a typo cannot silently arm it. It applies to the service
mode only; `--run-cli` ignores it.
Keep it off anywhere real. The service RPC signs over a master key only the client holds; here
there is no client key, so the service generates one and hands it over with the credential, which
means it can link every badge it issues this way. `simplex-chat badge sign` has the same property
and is the offline equivalent.
## Issuing codes
Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way
@@ -51,10 +228,21 @@ to start it — so codes are issued without a second process touching the servic
`months` defaults to 1 and must be between 1 and 255; the status defaults to `free` and records
whether the code was sold (`paid`), is awaiting payment (`unpaid`), or was issued by an operator
(`free`) — redemption never reads it.
(`free`). Redemption refuses an `unpaid` code with `payment_pending`: the web checkout writes the
code row when the invoice is created, and settlement is what marks it paid.
The code is printed once and only its `SHA-256` is stored, so a code that is not copied when it is
shown cannot be recovered.
A code that leaked, or that was refunded, is withdrawn the same way:
```
//revoke <code>
```
A revoked code answers redemption with `code_invalid`, as if it had never existed, so its holder
learns nothing from trying. Revoking is not repeatable: the second attempt says so. A code that
was already redeemed cannot be revoked: its badge was issued, and the command answers with an error.
Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is
why issuing codes lives in the service rather than in core.
@@ -0,0 +1,56 @@
[listener]
; bind locally; the reverse proxy terminates TLS. It must pass the query string through:
; the long poll's `wait`, `seenPaid` and `seenFull` all travel in it
host = 127.0.0.1
port = 8080
; required: the directory `npm run build` writes in apps/simplex-badge-service/web
static_dir = ./apps/simplex-badge-service/web/dist
; serve the webapp over HTTP. On (default) is the all-in-one deployment. Off is a split deployment
; where a reverse proxy serves the exported folder and this service is only the API and webhooks.
serve_webapp = on
; when set, the webapp is written here at boot with the publishable key injected, copied from
; static_dir, for a reverse proxy to serve. Leave unset in the all-in-one deployment.
; webapp_export_dir = /srv/webapp
; on behind the reverse proxy above: without it every request keys the rate limiter on the
; proxy's own address, so one bucket is shared by everyone. Set it off only where this
; listener is reached directly, since a client can write the header itself.
trust_forwarded_for = on
; omit this whole section to disable Bitcoin and Monero (the browser shows the provider-unavailable screen)
[btcpay]
host = https://btcpay.example.org
api_key = replace-me
store_id = replace-me
webhook_secret = replace-me
expiry_minutes = 60
speed_policy = MediumSpeed
payment_tolerance = 0.5
; omit this whole section to disable card payments (the browser shows the provider-unavailable screen)
[stripe]
; restricted key (rk_): Payment Intents write + Charges read — never a full secret key (sk_)
secret_key = rk_test_replace-me
publishable_key = pk_test_replace-me
; the signing secret of the /webhooks/stripe endpoint configured in the Dashboard
webhook_secret = whsec_replace-me
; minutes the service holds an unpaid card invoice open before it expires it; 1-1440
session_minutes = 60
[poll]
waiting_seconds = 3
idle_seconds = 60
; the key this service signs credentials with. index is the index clients verify against, so
; private_key has to be one whose public half already ships in the apps at that index: a fresh
; `simplex-chat badge keygen` pair is refused until it does.
; --issuer-key-idx and --issuer-secret override this section if both are given.
; to rotate, change index and private_key together, once clients trust the new index.
; the key is checked at startup, so this section stays commented out until it holds a real key.
;[issuer]
;index = 1
;private_key = replace-me
; local testing only: signs a credential for anyone who sends /redeem <code> over chat,
; with a master key this service generates and can therefore link
[dev]
chat_redeem = off
@@ -0,0 +1,135 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module BadgeService.Catalog
( OfferInvalid (..),
CatalogRefusal (..),
offerTotal,
PricedOffer (..),
priceOffer,
catalogCurrency,
defaultCatalog,
)
where
import Data.List (find)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Word (Word32, Word64, Word8)
import Simplex.Chat.Badges (BadgeType (..))
import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..))
import Simplex.Chat.Badges.Types (BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..))
import Simplex.Chat.PaymentService.Types (CurrencyAmount (..))
data OfferInvalid = OIZeroMonths | OIFreeMonthsExceedTerm | OIDiscountTooLarge | OIAmountUnsellable
deriving (Eq, Show)
offerTotal :: CurrencyAmount -> Maybe BadgeOffer -> Either OfferInvalid (Word8, CurrencyAmount, CurrencyAmount)
offerTotal (CurrencyAmount p) = \case
Nothing -> charge 1 (gross 1)
Just BadgeOffer {months, discount}
| months == 0 -> Left OIZeroMonths
| otherwise -> case discount of
ODFreeMonths f
| f >= months -> Left OIFreeMonthsExceedTerm
| otherwise -> charge months (gross (months - f))
ODDiscount d
| d >= 100 -> Left OIDiscountTooLarge
| otherwise -> charge months (gross months * (100 - fromIntegral d) `div` 100)
where
gross :: Word8 -> Word64
gross m = fromIntegral p * fromIntegral m
maxAmount :: Word64 -- $1,000,000 in minor units
maxAmount = 100000000
charge :: Word8 -> Word64 -> Either OfferInvalid (Word8, CurrencyAmount, CurrencyAmount)
charge m c
-- Bound gross too, not just the charge, or a large price wraps the Word32 amount field.
| c == 0 || c > maxAmount || gross m > maxAmount = Left OIAmountUnsellable
| otherwise = Right (m, CurrencyAmount (fromIntegral (gross m)), CurrencyAmount (fromIntegral c))
data PricedOffer = PricedOffer
{ poBadgeType :: BadgeType,
poMonths :: Word8,
poPrice :: CurrencyAmount,
poAmount :: CurrencyAmount,
poCurrency :: Text
}
deriving (Eq, Show)
data CatalogRefusal
= CRUnknownPrice
| CRDisabledPrice
| CRUnknownOffer
| CRDisabledOffer
| CROfferNotForPrice
| CRUnsoldBadgeType BadgeType
| CRUnpriced OfferInvalid
deriving (Eq, Show)
priceOffer :: [BadgePrice] -> [BadgeOffer] -> BadgePriceId -> Maybe BadgeOfferId -> Either CatalogRefusal PricedOffer
priceOffer prices offers wantedPriceId wantedOfferId = do
BadgePrice {badgeType, monthPrice, currency, status = priceStatus} <- known CRUnknownPrice (findPrice wantedPriceId)
soldBadgeType badgeType
active CRDisabledPrice priceStatus
chosenOffer <- resolveOffer wantedPriceId wantedOfferId
(months, price, amount) <- either (Left . CRUnpriced) Right (offerTotal monthPrice chosenOffer)
pure PricedOffer {poBadgeType = badgeType, poMonths = months, poPrice = price, poAmount = amount, poCurrency = currency}
where
known refusal = maybe (Left refusal) Right
findPrice p = find (\BadgePrice {priceId} -> priceId == p) prices
findOffer o = find (\BadgeOffer {offerId} -> offerId == o) offers
soldBadgeType bt
| bt == BTSupporter || bt == BTLegend = Right ()
| otherwise = Left (CRUnsoldBadgeType bt)
active _ BISActive = Right ()
active _ BISDeprecated = Right ()
active refusal BISDisabled = Left refusal
resolveOffer _ Nothing = Right Nothing
resolveOffer p (Just o) = do
chosen@BadgeOffer {priceId = offerPriceId, status = offerStatus} <- known CRUnknownOffer (findOffer o)
active CRDisabledOffer offerStatus
case offerPriceId of
Nothing -> Right (Just chosen)
Just op
| op == p -> Right (Just chosen)
| otherwise -> Left CROfferNotForPrice
catalogCurrency :: Text
catalogCurrency = "usd"
-- | Mirrors web/src/catalog.ts; seeded insert-only, so repricing needs a new price id.
defaultCatalog :: UTCTime -> ([BadgePrice], [BadgeOffer])
defaultCatalog seededAt = (prices, offers)
where
prices =
[ mkPrice "price_supporter" BTSupporter 700,
mkPrice "price_legend" BTLegend 7000
]
offers =
[ mkOffer "offer_3m" "price_legend" 3 (ODFreeMonths 1),
mkOffer "offer_12m" "price_legend" 12 (ODDiscount 50),
mkOffer "offer_3m_s" "price_supporter" 3 (ODFreeMonths 1),
mkOffer "offer_12m_s" "price_supporter" 12 (ODDiscount 50)
]
mkPrice :: Text -> BadgeType -> Word32 -> BadgePrice
mkPrice pId bType mPrice =
BadgePrice
{ priceId = BadgePriceId pId,
badgeType = bType,
monthPrice = CurrencyAmount mPrice,
currency = catalogCurrency,
status = BISActive,
createdAt = seededAt
}
mkOffer :: Text -> Text -> Word8 -> OfferDiscount -> BadgeOffer
mkOffer oId pId mMonths mDiscount =
BadgeOffer
{ offerId = BadgeOfferId oId,
priceId = Just (BadgePriceId pId),
months = mMonths,
discount = mDiscount,
status = BISActive,
createdAt = seededAt
}
@@ -0,0 +1,284 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module BadgeService.Config
( ListenerConfig (..),
BTCPayConfig (..),
StripeConfig (..),
SpeedPolicy (..),
speedPolicyName,
PollConfig (..),
BadgeIssuerKey (..),
ServiceConfig (..),
defaultExpiryMinutes,
defaultSessionMinutes,
readServiceConfig,
unknownKeys,
)
where
import qualified Control.Exception as E
import BadgeService.Log (logWarn)
import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile)
import qualified Data.ByteString.Char8 as B
import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Simplex.Messaging.Crypto.BBS (BBSSecretKey)
import Simplex.Messaging.Encoding.String (strDecode)
import System.IO.Error (ioeGetErrorString)
import Text.Read (readMaybe)
data ListenerConfig = ListenerConfig
{ lHost :: Text,
lPort :: Int,
lStaticDir :: FilePath,
lServeWebapp :: Bool,
lWebappExportDir :: Maybe FilePath,
lTrustForwardedFor :: Bool
}
deriving (Eq, Show)
-- | Sent by name, since BTCPay's numbering is not in speed order.
data SpeedPolicy = HighSpeed | MediumSpeed | LowMediumSpeed | LowSpeed
deriving (Bounded, Enum, Eq, Show)
speedPolicies :: [SpeedPolicy]
speedPolicies = [minBound .. maxBound]
speedPolicyName :: SpeedPolicy -> Text
speedPolicyName = \case
HighSpeed -> "HighSpeed"
MediumSpeed -> "MediumSpeed"
LowMediumSpeed -> "LowMediumSpeed"
LowSpeed -> "LowSpeed"
data BTCPayConfig = BTCPayConfig
{ bHost :: Text,
bApiKey :: Text,
bStoreId :: Text,
bWebhookSecret :: Text,
bExpiryMinutes :: Int,
bSpeedPolicy :: SpeedPolicy,
bPaymentTolerance :: Double
}
deriving (Eq)
-- Hand-written to keep the api key and webhook secret out of logs.
instance Show BTCPayConfig where
show BTCPayConfig {bHost, bStoreId} = "btcpay " <> T.unpack bHost <> " store " <> T.unpack bStoreId
data StripeConfig = StripeConfig
{ sSecretKey :: Text,
sPublishableKey :: Text,
sWebhookSecret :: Text,
sSessionMinutes :: Int,
sHost :: Text
}
deriving (Eq)
-- Hand-written to keep the restricted key and signing secret out of logs.
instance Show StripeConfig where
show StripeConfig {sHost} = "stripe " <> T.unpack sHost
data PollConfig = PollConfig {pWaitingSeconds :: Int, pIdleSeconds :: Int}
deriving (Eq, Show)
data BadgeIssuerKey = BadgeIssuerKey
{ keyIdx :: Int,
secretKey :: BBSSecretKey
}
deriving (Eq)
-- BBSSecretKey derives Show, so this is written out to keep the secret out of logs and errors
instance Show BadgeIssuerKey where
show BadgeIssuerKey {keyIdx} = "issuer key " <> show keyIdx
data ServiceConfig = ServiceConfig
{ listener :: ListenerConfig,
btcpay :: Maybe BTCPayConfig,
stripe :: Maybe StripeConfig,
poll :: PollConfig,
issuer :: Maybe BadgeIssuerKey,
-- Local testing only; signs credentials with a master key this service can link.
devChatRedeem :: Bool
}
deriving (Eq, Show)
defaultExpiryMinutes :: Int
defaultExpiryMinutes = 60
defaultStripeHost :: Text
defaultStripeHost = "https://api.stripe.com"
defaultSessionMinutes :: Int
defaultSessionMinutes = 60
-- | At most one day, so a card order expires long before the poller stops checking it at 72 hours.
minSessionMinutes, maxSessionMinutes :: Int
minSessionMinutes = 1
maxSessionMinutes = 1440
-- | An http host would carry the API key in the clear on every call.
requireHttps :: Text -> Either String Text
requireHttps u
| "https://" `T.isPrefixOf` u = Right u
| otherwise = Left "btcpay.host must be an absolute https URL"
-- | Well below 100, where BTCPay settles an invoice for one satoshi.
maxTolerance :: Double
maxTolerance = 10
readServiceConfig :: FilePath -> IO (Either String ServiceConfig)
readServiceConfig path =
E.try (TIO.readFile path) >>= \case
Left (e :: E.IOException) -> pure (Left (ioeGetErrorString e))
-- Data.Ini stops at the first unparseable line and keeps what it has, so a missing `=` silently drops every section below.
Right text -> case parseOnly (iniParser <* trailingNoise <* endOfInput) text of
Left _ -> pure (Left "could not be read as an ini file: a line is malformed")
Right ini -> do
mapM_ (\k -> logWarn (T.pack path <> ": nothing reads " <> k <> ", so it was ignored")) (unknownKeys ini)
pure (parseConfig ini)
-- | iniParser stops before a trailing comment, so endOfInput alone would reject a valid file.
trailingNoise :: Parser ()
trailingNoise = skipSpace *> skipMany (comment *> skipSpace)
where
comment = satisfy (\c -> c == ';' || c == '#') *> skipWhile (not . isEndOfLine)
knownSettings :: [(Text, [Text])]
knownSettings =
[ ("listener", ["host", "port", "static_dir", "serve_webapp", "webapp_export_dir", "trust_forwarded_for"]),
("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]),
("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]),
("poll", ["waiting_seconds", "idle_seconds"]),
("dev", ["chat_redeem"]),
("issuer", ["index", "private_key"])
]
unknownKeys :: Ini -> [Text]
unknownKeys ini = beforeAnySection <> unknownSections <> settings
where
beforeAnySection = [k <> ", written above the first section header" | (k, _) <- iniGlobals ini]
unknownSections = ["[" <> s <> "]" | s <- sections ini, T.strip s `notElem` map fst knownSettings]
settings =
[ section <> "." <> key
| (section, known) <- knownSettings,
key <- either (const []) id (keys section ini),
T.strip key `notElem` known
]
parseConfig :: Ini -> Either String ServiceConfig
parseConfig ini = do
lStaticDir <- T.unpack <$> required "listener" "static_dir"
lHost <- optional "listener" "host" "127.0.0.1"
lPort <- do
p <- num "listener" "port" 8080
if 1 <= p && p <= 65535 then Right p else Left "listener.port must be between 1 and 65535"
lServeWebapp <- bool "listener" "serve_webapp" True
let lWebappExportDir = case fmap T.strip (look "listener" "webapp_export_dir") of
Just v | not (T.null v) -> Just (T.unpack v)
_ -> Nothing
lTrustForwardedFor <- bool "listener" "trust_forwarded_for" False
btc <- btcpaySection
str <- stripeSection
iss <- issuerSection
pWaitingSeconds <- cadence "waiting_seconds" 3
pIdleSeconds <- cadence "idle_seconds" 60
devRedeem <- bool "dev" "chat_redeem" False
pure
ServiceConfig
{ listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor},
btcpay = btc,
stripe = str,
poll = PollConfig {pWaitingSeconds, pIdleSeconds},
issuer = iss,
devChatRedeem = devRedeem
}
where
hasSection s = s `elem` sections ini
look s k = either (const Nothing) Just (lookupValue s k ini)
required s k = case look s k of
Just v | not (T.null (T.strip v)) -> Right (T.strip v)
_ -> Left (T.unpack s <> "." <> T.unpack k <> " is required")
optional s k d = case fmap T.strip (look s k) of
Just v | not (T.null v) -> Right v
_ -> Right d
-- Integer, because readMaybe at Int wraps silently, reading 2^64+4 as 4.
num s k d = case look s k of
Nothing -> Right d
Just v -> case readMaybe (T.unpack (T.strip v)) of
Just n | n >= toInteger (minBound :: Int), n <= toInteger (maxBound :: Int) -> Right (fromInteger n)
_ -> Left (T.unpack k <> " must be a whole number")
bool s k d = case fmap (T.toLower . T.strip) (look s k) of
Nothing -> Right d
Just "on" -> Right True
Just "off" -> Right False
Just other -> Left (T.unpack k <> " must be on or off, not " <> T.unpack other)
-- num accepts 0 and negatives, and a zero cadence is a busy loop.
cadence k d = do
v <- num "poll" k d
if v >= 1 then Right v else Left ("poll." <> T.unpack k <> " must be at least 1 second")
issuerSection
| not (hasSection "issuer") = Right Nothing
| otherwise = do
keyIdx <- issuerIndex
secretKey <- issuerSecret
pure (Just BadgeIssuerKey {keyIdx, secretKey})
issuerIndex = do
v <- required "issuer" "index"
case readMaybe (T.unpack v) of
Just n | n >= 1, n <= toInteger (maxBound :: Int) -> Right (fromInteger n)
_ -> Left "issuer.index must be a positive whole number"
issuerSecret = do
v <- required "issuer" "private_key"
either (const (Left "issuer.private_key is not a valid issuer secret; use the value from `simplex-chat badge keygen`")) Right $
strDecode (B.pack (T.unpack v))
btcpaySection
| not (hasSection "btcpay") = Right Nothing
| otherwise = do
bHost <- required "btcpay" "host" >>= requireHttps
bApiKey <- required "btcpay" "api_key"
bStoreId <- required "btcpay" "store_id"
bWebhookSecret <- required "btcpay" "webhook_secret"
bExpiryMinutes <- expiryMinutes
bSpeedPolicy <- speedPolicy
bPaymentTolerance <- tolerance
pure (Just BTCPayConfig {bHost, bApiKey, bStoreId, bWebhookSecret, bExpiryMinutes, bSpeedPolicy, bPaymentTolerance})
-- A negative window puts BTCPay's startDate in the future, so every poll comes back empty.
expiryMinutes = do
v <- num "btcpay" "expiry_minutes" defaultExpiryMinutes
if v >= 1 then Right v else Left "btcpay.expiry_minutes must be at least 1 minute"
speedPolicy = case look "btcpay" "speed_policy" of
Nothing -> Right MediumSpeed
Just v -> case lookup (T.strip v) [(speedPolicyName p, p) | p <- speedPolicies] of
Just p -> Right p
Nothing ->
Left
( "btcpay.speed_policy must be one of "
<> T.unpack (T.intercalate ", " (map speedPolicyName speedPolicies))
<> ", not "
<> T.unpack (T.strip v)
)
tolerance = case look "btcpay" "payment_tolerance" of
Nothing -> Right 0.5
Just v -> case readMaybe (T.unpack (T.strip v)) of
Just d | d >= 0 && d <= maxTolerance -> Right d
_ -> Left ("btcpay.payment_tolerance must be a percentage between 0 and " <> show maxTolerance)
stripeSection
| not (hasSection "stripe") = Right Nothing
| otherwise = do
sSecretKey <- required "stripe" "secret_key"
sPublishableKey <- required "stripe" "publishable_key"
sWebhookSecret <- required "stripe" "webhook_secret"
sSessionMinutes <- sessionMinutes
let sHost = defaultStripeHost
pure (Just StripeConfig {sSecretKey, sPublishableKey, sWebhookSecret, sSessionMinutes, sHost})
sessionMinutes = do
v <- num "stripe" "session_minutes" defaultSessionMinutes
if v >= minSessionMinutes && v <= maxSessionMinutes
then Right v
else Left ("stripe.session_minutes must be between " <> show minSessionMinutes <> " and " <> show maxSessionMinutes <> " minutes")
@@ -0,0 +1,36 @@
{-# LANGUAGE OverloadedStrings #-}
-- | The SMP agent logs unconditionally at info on the global logger, so the service keeps its own info lines on this separate channel when that logger is raised to Warn.
module BadgeService.Log
( logInfo,
logWarn,
logError,
)
where
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (getZonedTime)
import GHC.Stack (CallStack, HasCallStack, callStack, getCallStack, srcLocFile, srcLocStartLine)
import System.IO (stderr)
logInfo :: (HasCallStack, MonadIO m) => Text -> m ()
logInfo = logAt "INFO" callStack
logWarn :: (HasCallStack, MonadIO m) => Text -> m ()
logWarn = logAt "WARN" callStack
logError :: (HasCallStack, MonadIO m) => Text -> m ()
logError = logAt "ERROR" callStack
logAt :: MonadIO m => Text -> CallStack -> Text -> m ()
logAt tag cs msg = liftIO $ do
ts <- formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S %z" <$> getZonedTime
T.hPutStrLn stderr $ "[" <> tag <> " " <> T.pack ts <> " " <> loc <> "] " <> msg
where
loc = case getCallStack cs of
((_, l) : _) -> T.pack (srcLocFile l) <> ":" <> T.pack (show (srcLocStartLine l))
[] -> "unknown"
@@ -5,19 +5,18 @@
module BadgeService.Options
( BadgeServiceOpts (..),
BadgeIssuerKey (..),
getBadgeServiceOpts,
badgeServiceOpts,
mkChatOpts,
)
where
import BadgeService.Config (BadgeIssuerKey (..))
import qualified Data.ByteString.Char8 as B
import qualified Data.Text as T
import Options.Applicative
import Simplex.Chat.Controller (updateStr, versionNumber, versionString)
import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP)
import Simplex.Messaging.Crypto.BBS (BBSSecretKey)
import Simplex.Messaging.Encoding.String (strDecode)
data BadgeServiceOpts = BadgeServiceOpts
@@ -26,21 +25,11 @@ data BadgeServiceOpts = BadgeServiceOpts
clientService :: Bool,
noAddress :: Bool,
runCLI :: Bool,
-- the service refuses to start without this: it cannot sign a credential
issuerKey :: Maybe BadgeIssuerKey,
serviceConfigFile :: Maybe FilePath,
issuerKey :: Either String (Maybe BadgeIssuerKey),
testing :: Bool
}
-- | The issuer secret that signs credentials, and the index the apps find its public half under.
data BadgeIssuerKey = BadgeIssuerKey
{ keyIdx :: Int,
secretKey :: BBSSecretKey
}
-- BBSSecretKey derives Show, so this is written out to keep the secret out of logs and errors
instance Show BadgeIssuerKey where
show BadgeIssuerKey {keyIdx} = "issuer key " <> show keyIdx
badgeServiceOpts :: FilePath -> FilePath -> Parser BadgeServiceOpts
badgeServiceOpts appDir defaultDbName = do
coreOptions <- coreChatOptsP appDir defaultDbName
@@ -66,6 +55,14 @@ badgeServiceOpts appDir defaultDbName = do
( long "run-cli"
<> help "Run badge service as CLI"
)
serviceConfigFile <-
optional
( strOption
( long "service-config"
<> metavar "INI_FILE"
<> help "Path to badge_service.ini: parsed in full in every mode, but --run-cli starts no web listener"
)
)
issuerKeyIdx <-
optional $
option
@@ -89,7 +86,11 @@ badgeServiceOpts appDir defaultDbName = do
clientService,
noAddress,
runCLI,
issuerKey = BadgeIssuerKey <$> issuerKeyIdx <*> issuerSecret,
serviceConfigFile,
issuerKey = case (issuerKeyIdx, issuerSecret) of
(Just idx, Just secret) -> Right (Just (BadgeIssuerKey idx secret))
(Nothing, Nothing) -> Right Nothing
_ -> Left "--issuer-key-idx and --issuer-secret are given together or not at all",
testing = False
}
@@ -0,0 +1,117 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module BadgeService.Orders (settleOrder, decide, codeLifetime) where
import BadgeService.Providers (Funded (..), PaymentSignal (..), Received (..))
import BadgeService.Store.Invoices (InvoicePayment (..), InvoiceRow (..), markCodePaid, paymentStatusText, settlementCodeHash, settlementInvoice, truncateToSecond, updateInvoiceStatus, upsertPayment)
import BadgeService.Waiters (Waiters, publish, publishPayment)
import Control.Concurrent.STM (atomically)
import Control.Monad (when)
import Data.ByteString (ByteString)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime)
import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), InvoiceId, InvoiceStatus (..), PaymentStatus (..))
import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction)
import qualified Simplex.Messaging.Agent.Store.DB as DB
-- | Written onto the row at settlement and read back, so changing it moves only codes sold afterwards.
codeLifetime :: NominalDiffTime
codeLifetime = 365 * 24 * 60 * 60
data Write = Write
{ wStatus :: Maybe InvoiceStatus,
wPayment :: PaymentStatus,
wCode :: Bool
}
decide :: InvoiceStatus -> PaymentSignal -> Maybe Write
decide ISPaid _ = Nothing
decide _ SigSettled {} = Just Write {wStatus = Just ISPaid, wPayment = PSSettled, wCode = True}
decide _ SigFunded {} = Just Write {wStatus = Nothing, wPayment = PSPending, wCode = False}
decide ISOpen SigClosed {} = Just Write {wStatus = Just ISExpired, wPayment = PSPending, wCode = False}
decide ISExpired SigClosed {} = Just Write {wStatus = Nothing, wPayment = PSPending, wCode = False}
paidInFull :: PaymentSignal -> Bool
paidInFull = \case
SigFunded _ f -> f == PaidInFull
SigSettled {} -> True
SigClosed {} -> False
received :: PaymentSignal -> Received
received = \case
SigFunded r _ -> r
SigSettled r _ -> r
SigClosed r -> r
-- | Uses the provider's settlement instant, not now, so an outage does not push deadlines out;
-- an implausible value (zero, milliseconds, negative) falls back to now.
settledInstant :: PaymentSignal -> UTCTime -> UTCTime
settledInstant signal now = case signal of
SigSettled _ at | at <= now, at >= addUTCTime (negate maxBackdate) now -> at
_ -> now
-- | Longer than any outage the poller must survive, far short of the code's lifetime.
maxBackdate :: NominalDiffTime
maxBackdate = 30 * 24 * 60 * 60
data Published = PubNothing | PubStatus InvoiceStatus | PubPayment
settleOrder :: DBStore -> Waiters -> InvoiceId -> PaymentSignal -> UTCTime -> IO (Either Text InvoiceStatus)
settleOrder st waiters invId signal now' = do
outcome <- withTransaction st $ \db ->
settlementInvoice db invId >>= \case
Nothing -> pure (Left "no such invoice")
Just row@InvoiceRow {irStatus} -> case decide irStatus signal of
Nothing -> pure (Right (irStatus, PubNothing))
-- look up the code first, so an invoice with none leaves the transaction empty, not paid with an unpaid code
Just w -> codeToMark db w >>= either (pure . Left) (settle db row w)
case outcome of
Left e -> pure (Left e)
Right (status, toPublish) -> do
-- after the commit, or a woken reader will not see the write
atomically $ case toPublish of
PubStatus s -> publish waiters invId s
PubPayment -> publishPayment waiters invId
PubNothing -> pure ()
pure (Right status)
where
now = truncateToSecond now'
at = truncateToSecond (settledInstant signal now)
codeToMark :: DB.Connection -> Write -> IO (Either Text (Maybe ByteString))
codeToMark db Write {wCode}
| not wCode = pure (Right Nothing)
| otherwise = maybe (Left "settled invoice has no code hash") (Right . Just) <$> settlementCodeHash db invId
newPayment :: InvoiceRow -> Write -> Bool
newPayment InvoiceRow {irPayment} Write {wPayment}
-- Monero reports an invoice as confirming while its figures are still zero, so a funded verdict earns a row
| not (paidInFull signal) && rcvCrypto == Nothing && rcvAmount == CurrencyAmount 0 = False
| otherwise = case irPayment of
Nothing -> True
-- the write is monotonic, so a lower figure or withdrawn verdict is not a new payment
Just InvoicePayment {ipAmount, ipCryptoPaid, ipPaidInFull, ipStatus}
| ipStatus == paymentStatusText PSSettled -> False
| otherwise ->
maybe True (\(CurrencyAmount held) -> held < minor) ipAmount
|| (ipCryptoPaid == Nothing && rcvCrypto /= Nothing)
|| (paidInFull signal && not ipPaidInFull)
|| ipStatus /= paymentStatusText wPayment
where
Received {rcvAmount, rcvCrypto} = received signal
CurrencyAmount minor = rcvAmount
settle :: DB.Connection -> InvoiceRow -> Write -> Maybe ByteString -> IO (Either Text (InvoiceStatus, Published))
settle db row@InvoiceRow {irStatus} w@Write {wStatus, wPayment} codeHash = do
let Received {rcvAmount, rcvCrypto, rcvDue} = received signal
wrotePayment = newPayment row w
when wrotePayment $ upsertPayment db row wPayment rcvAmount rcvCrypto rcvDue (paidInFull signal) at
moved <- maybe (pure True) (\new -> updateInvoiceStatus db invId irStatus new at) wStatus
if moved
then do
mapM_ (\h -> markCodePaid db h (addUTCTime codeLifetime at)) codeHash
pure (Right (fromMaybe irStatus wStatus, maybe (if wrotePayment then PubPayment else PubNothing) PubStatus wStatus))
else do
-- another writer moved the row first, so publish what we found
current <- maybe irStatus (\InvoiceRow {irStatus = s} -> s) <$> settlementInvoice db invId
pure (Right (current, PubStatus current))
@@ -0,0 +1,376 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module BadgeService.Poller
( PollerEnv (..),
ReadHints,
newReadHints,
queueReadHint,
hintQueueSize,
newPollerEnv,
runOnePass,
drainHints,
passDue,
runPoller,
passDelay,
SkipOwner (..),
skipOwner,
dueToWarn,
expiryGrace,
cancelGiveUpDelay,
readsPerPass,
skipWarnInterval,
maxSkipReasons,
)
where
import BadgeService.Config (PollConfig (..))
import BadgeService.Orders (decide, settleOrder)
import BadgeService.Providers (ListPass (..), PaymentSignal (..), Provider (..), ProviderError (..), Received (..), expiresItself, settleWindow)
import BadgeService.Store.Invoices (InvoiceRow (..), OverdueInvoice (..), expireOverdue, getInvoiceByProviderRef, overdueInvoices, providerText, unpaidRefs)
import BadgeService.Waiters (Waiters, publish, waitingCount, waitingCountSTM)
import Control.Concurrent.STM
import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try)
import BadgeService.Log (logError, logInfo, logWarn)
import Control.Monad (filterM, forever, unless, void, when)
import Data.List (find, partition, sortOn)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust)
import Data.Set (Set)
import qualified Data.Set as S
import Data.Text (Text)
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import Numeric.Natural (Natural)
import Simplex.Chat.PaymentService.Types (InvoiceStatus (..), PaymentProvider)
import Simplex.Messaging.Agent.Store.Common (DBStore)
import Simplex.Messaging.Util (tshow)
-- | Allows for our clock running ahead of the provider's; an expired invoice can still be marked paid.
expiryGrace :: NominalDiffTime
expiryGrace = 600
-- | Give up on a failing cancel only after an hour, not on the first failure.
cancelGiveUpDelay :: NominalDiffTime
cancelGiveUpDelay = 3600
skipWarnInterval :: NominalDiffTime
skipWarnInterval = 3600
maxSkipReasons :: Int
maxSkipReasons = 4096
-- | Webhooks queue reads here, so all settling happens on the poller thread.
-- An order already waiting in the queue is not added again, so repeated webhooks don't pile up reads.
data ReadHints = ReadHints (TBQueue Text) (TVar (Set Text))
hintQueueSize :: Natural
hintQueueSize = 256
newReadHints :: IO ReadHints
newReadHints = ReadHints <$> newTBQueueIO hintQueueSize <*> newTVarIO S.empty
queueReadHint :: ReadHints -> Text -> IO Bool
queueReadHint (ReadHints q queued) ref = atomically $ do
already <- S.member ref <$> readTVar queued
full <- isFullTBQueue q
if
| already -> pure True
| full -> pure False
| otherwise -> do
writeTBQueue q ref
modifyTVar' queued (S.insert ref)
pure True
minCadenceSeconds :: Int
minCadenceSeconds = 1
data PollerEnv = PollerEnv
{ peStore :: DBStore,
peWaiters :: Waiters,
peHints :: ReadHints,
peProviders :: [Provider],
pePoll :: PollConfig,
peSkipped :: TVar (Map Text UTCTime),
peListedAt :: TVar (Maybe UTCTime),
peStrayEvery :: NominalDiffTime,
-- When the cancel first failed, for orders older than 72 hours.
peCancelFailed :: TVar (Map Text UTCTime)
}
newPollerEnv :: DBStore -> Waiters -> ReadHints -> [Provider] -> PollConfig -> IO PollerEnv
newPollerEnv peStore peWaiters peHints peProviders pePoll = do
peSkipped <- newTVarIO M.empty
peListedAt <- newTVarIO Nothing
peCancelFailed <- newTVarIO M.empty
pure PollerEnv {peStore, peWaiters, peHints, peProviders, pePoll, peSkipped, peListedAt, peStrayEvery = strayListInterval, peCancelFailed}
-- | Read providers before the sweep, and skip the sweep entirely when a read failed, so an
-- invoice with money in it is never expired.
runOnePass :: PollerEnv -> IO ()
runOnePass env@PollerEnv {peStore, peProviders} = do
now <- getCurrentTime
rows <- unpaidRefs peStore (addUTCTime (negate settleWindow) now)
let bulk = length rows > readsPerPass
listNow <- listDue env now bulk
accounted <-
if bulk
then do
covered <- and <$> mapM (rowIsCovered env now) rows
(covered &&) . and <$> mapM (listPass env now) peProviders
else do
read' <- readRows env now rows
when listNow $ mapM_ (listPass env now) peProviders
pure read'
pruneSkipLog env now
when (accounted && not (null peProviders)) $ sweepExpired env now
-- | The cases the stray list catches are rare and not urgent, so minutes, not the pass cadence.
strayListInterval :: NominalDiffTime
strayListInterval = 60
listDue :: PollerEnv -> UTCTime -> Bool -> IO Bool
listDue PollerEnv {peListedAt, peStrayEvery} now forced = atomically $ do
last' <- readTVar peListedAt
let due = forced || maybe True (\at -> diffUTCTime now at >= peStrayEvery) last'
when due $ writeTVar peListedAt (Just now)
pure due
-- | Past this many open invoices one list is fewer bytes and requests than reading each.
readsPerPass :: Int
readsPerPass = 25
coveringProvider :: PollerEnv -> UTCTime -> (Text, Text) -> IO (Maybe Provider)
coveringProvider env@PollerEnv {peProviders} now (provider, ref) =
case find ((== provider) . providerText . pProvider) peProviders of
Just p -> pure (Just p)
Nothing -> do
due <- dueToWarn env now ("no provider for " <> provider)
when due $ logError ("badge poller: invoice " <> ref <> " names provider " <> provider <> ", which this build has none of")
pure Nothing
rowIsCovered :: PollerEnv -> UTCTime -> (Text, Text) -> IO Bool
rowIsCovered env now row = isJust <$> coveringProvider env now row
readRows :: PollerEnv -> UTCTime -> [(Text, Text)] -> IO Bool
readRows env now rows =
and <$> mapM (\r -> safelyWith (readWhat r) False (readRow r)) rows
where
readWhat (provider, ref) = "reading " <> provider <> " invoice " <> ref
readRow row@(_, ref) = coveringProvider env now row >>= maybe (pure False) (`readOne` ref)
readOne p ref =
pReadInvoice p ref >>= \case
Left (ProviderError e) -> do
due <- dueToWarn env now ("read failed: " <> tshow (pProvider p))
when due $ logWarn ("badge poller: " <> tshow (pProvider p) <> " reads are failing; every invoice waits for the next pass: " <> e)
pure False
Right Nothing -> pure True
Right (Just signal) -> True <$ settleMoved env (pProvider p) now (ref, signal)
-- | False when the pass cannot account for every invoice sold, so the sweep does not expire one
-- over money it missed.
listPass :: PollerEnv -> UTCTime -> Provider -> IO Bool
listPass env now p =
pListOpen p >>= \case
Left (ProviderError e) -> do
due <- dueToWarn env now ("list failed: " <> tshow (pProvider p))
when due $ logWarn ("badge poller: " <> tshow (pProvider p) <> " list failed; every invoice waits for the next pass: " <> e)
pure False
Right ListPass {lpMoved, lpSkipped} -> do
owners <- mapM (\s -> safelyWith (skipWhat s) SkipUnaccounted (reportSkip env (pProvider p) now s)) lpSkipped
settled <- mapM (\m -> safely (settleWhat m) (settleMoved env (pProvider p) now m)) lpMoved
pure (all (== SkipStranger) owners && and settled)
where
settleWhat (ref, _) = "settling " <> tshow (pProvider p) <> " invoice " <> ref
skipWhat (ref, _) = "reading the skipped " <> tshow (pProvider p) <> " invoice " <> fromMaybe "the provider did not name" ref
-- | provider_ref is unique table-wide, not per provider, so check the provider too.
settleMoved :: PollerEnv -> PaymentProvider -> UTCTime -> (Text, PaymentSignal) -> IO ()
settleMoved env@PollerEnv {peStore, peWaiters} provider now (ref, signal) =
getInvoiceByProviderRef peStore ref >>= \case
Just InvoiceRow {irInvoiceId, irStatus, irProvider} | irProvider == provider ->
when (isJust (decide irStatus signal)) $
settleOrder peStore peWaiters irInvoiceId signal now >>= \case
Left e -> logError ("badge poller: settling order " <> ref <> " failed: " <> e)
Right status -> reportSettled irStatus status
_ -> pure ()
where
reportSettled before after
-- checked before the no-change case, so a refund still alerts when money lands after expiry
| after == ISExpired, SigClosed Received {rcvCrypto = Just paid} <- signal = do
let alert = "badge poller: order " <> ref <> " expired holding " <> paid <> ", which needs a refund"
due <- dueToWarn env now alert
when due $ logError alert
| before == after = pure ()
| otherwise = logInfo ("badge poller: order " <> ref <> " " <> tshow before <> " -> " <> tshow after)
serveHint :: PollerEnv -> Text -> IO ()
serveHint env ref = do
now <- getCurrentTime
hintSafely env now ref
readHint :: PollerEnv -> UTCTime -> Text -> IO ()
readHint env@PollerEnv {peStore, peProviders} now ref =
getInvoiceByProviderRef peStore ref >>= \case
Nothing -> pure ()
Just InvoiceRow {irProvider} -> case find ((== irProvider) . pProvider) peProviders of
Nothing -> pure ()
Just p ->
pReadInvoice p ref >>= \case
Left (ProviderError e) ->
logWarn ("badge poller: the hinted read of " <> ref <> " failed; the next pass will run: " <> e)
Right Nothing -> pure ()
Right (Just signal) -> settleMoved env irProvider now (ref, signal)
sweepExpired :: PollerEnv -> UTCTime -> IO ()
sweepExpired env@PollerEnv {peStore, peWaiters, peCancelFailed} now = do
let cutoff = addUTCTime (negate expiryGrace) now
(selfExpiring, payable) <- partition (expiresItself . oiProvider) <$> overdueInvoices peStore cutoff
atomically $ modifyTVar' peCancelFailed (`M.restrictKeys` S.fromList (map oiProviderRef payable))
-- Cancel only a few orders per pass, so a long list does not block other work.
cancelled <- filterM (closedAtProvider env now) (take readsPerPass payable)
expired <- expireOverdue peStore cutoff (map oiInvoiceId (selfExpiring <> cancelled))
-- publish only after the write commits, or a woken reader will not see it
unless (null expired) $ do
atomically $ mapM_ (\invId -> publish peWaiters invId ISExpired) expired
logInfo ("badge poller: expired " <> tshow (length expired) <> " invoice(s) past their window")
-- | Cancel the order at the provider first, so the buyer can no longer pay it.
closedAtProvider :: PollerEnv -> UTCTime -> OverdueInvoice -> IO Bool
closedAtProvider env@PollerEnv {peProviders} now oi@OverdueInvoice {oiProvider, oiProviderRef = ref} =
safelyWith ("cancelling " <> tshow oiProvider <> " invoice " <> ref) False $
case find ((== oiProvider) . pProvider) peProviders of
Nothing -> uncancelled env now oi "no provider is configured for it"
Just p ->
pCancelInvoice p ref >>= \case
Right () -> pure True
-- The provider won't cancel an order that is already paid or closed. Reading it shows which.
Left (ProviderError cancelError) ->
pReadInvoice p ref >>= \case
Right (Just signal) -> False <$ settleMoved env oiProvider now (ref, signal)
Right Nothing -> uncancelled env now oi cancelError
Left (ProviderError readError) -> uncancelled env now oi (cancelError <> "; reading it failed too: " <> readError)
-- | After 72 hours the poller only reads an order when a webhook asks. If an older order still
-- can't be cancelled after an hour of trying, mark it expired here and log an error.
uncancelled :: PollerEnv -> UTCTime -> OverdueInvoice -> Text -> IO Bool
uncancelled env@PollerEnv {peCancelFailed} now OverdueInvoice {oiProvider, oiProviderRef = ref, oiCreatedAt} e
| diffUTCTime now oiCreatedAt <= settleWindow = keepOpen "it stays open until a cancel succeeds or it settles"
| otherwise = do
firstFailed <- atomically $ stateTVar peCancelFailed $ \failed -> (M.lookup ref failed, M.insertWith (\_ first -> first) ref now failed)
case firstFailed of
Just at | diffUTCTime now at >= cancelGiveUpDelay -> do
logError ("badge poller: overdue " <> tshow oiProvider <> " invoice " <> ref <> " could not be cancelled at the provider, so it was expired here only; check it in the provider's dashboard: " <> e)
pure True
_ -> keepOpen "it is expired here if the cancel still fails an hour after its first failure"
where
keepOpen outcome = do
due <- dueToWarn env now ("cancel failed: " <> tshow oiProvider <> " invoice " <> ref)
when due $ logWarn ("badge poller: cancelling overdue " <> tshow oiProvider <> " invoice " <> ref <> " failed; " <> outcome <> ": " <> e)
pure False
data SkipOwner
= SkipOurs
| SkipStranger
| -- | The provider named no invoice, so this skip could be any of ours.
SkipUnaccounted
deriving (Eq, Show)
skipOwner :: PollerEnv -> PaymentProvider -> Maybe Text -> IO SkipOwner
skipOwner PollerEnv {peStore} provider = \case
Nothing -> pure SkipUnaccounted
Just ref ->
getInvoiceByProviderRef peStore ref >>= \case
Just InvoiceRow {irProvider} | irProvider == provider -> pure SkipOurs
_ -> pure SkipStranger
reportSkip :: PollerEnv -> PaymentProvider -> UTCTime -> (Maybe Text, Text) -> IO SkipOwner
reportSkip env provider now (ref, reason) = do
owner <- skipOwner env provider ref
due <- dueToWarn env now reason
when due $ case owner of
SkipOurs -> logError ("badge poller: an invoice this service sold was not read, so its payment cannot be detected: " <> reason)
SkipUnaccounted -> logError ("badge poller: part of the window was not read, so a payment to any invoice in it cannot be detected: " <> reason)
SkipStranger -> logWarn ("badge poller: the list pass could not read everything: " <> reason)
pure owner
dueToWarn :: PollerEnv -> UTCTime -> Text -> IO Bool
dueToWarn PollerEnv {peSkipped} now reason = atomically $ do
seen <- readTVar peSkipped
let due = case M.lookup reason seen of
Nothing -> True
Just at -> diffUTCTime now at >= skipWarnInterval
when due $ writeTVar peSkipped (M.insert reason now seen)
pure due
pruneSkipLog :: PollerEnv -> UTCTime -> IO ()
pruneSkipLog PollerEnv {peSkipped} now = atomically $ modifyTVar' peSkipped prune
where
prune seen
| M.size seen <= maxSkipReasons = seen
| M.size fresh <= maxSkipReasons = fresh
| otherwise = M.fromList (drop (M.size fresh - maxSkipReasons) (sortOn snd (M.toList fresh)))
where
fresh = M.filter (\at -> diffUTCTime now at < skipWarnInterval) seen
passDelay :: PollConfig -> Int -> Int
passDelay PollConfig {pWaitingSeconds, pIdleSeconds} waiting =
1000000 * max minCadenceSeconds (if waiting > 0 then pWaitingSeconds else pIdleSeconds)
waitingDelay :: PollConfig -> Int
waitingDelay cfg = passDelay cfg 1
-- | Both timers start now, not when a browser arrives, so browsers cannot make us poll faster than the short cadence.
passDue :: PollerEnv -> IO (STM ())
passDue PollerEnv {peWaiters, pePoll} = do
waiting <- waitingCount peWaiters
soonest <- registerDelay (waitingDelay pePoll)
full <- registerDelay (passDelay pePoll waiting)
pure $
(readTVar full >>= check)
`orElse` ( do
readTVar soonest >>= check
waitingCountSTM peWaiters >>= \n -> check (n > 0)
)
runPoller :: PollerEnv -> IO ()
runPoller env = forever $ do
passSafely env
passDue env >>= serveHints env
-- | The deadline is checked per hint, not per batch, so a redelivery backlog cannot block the pass for the sum of their timeouts.
serveHints :: PollerEnv -> STM () -> IO ()
serveHints env@PollerEnv {peHints = ReadHints q queued} due = do
next <- atomically ((Nothing <$ due) `orElse` (Just <$> takeHint))
case next of
Nothing -> pure ()
Just ref -> serveHint env ref >> serveHints env due
where
takeHint = do
ref <- readTBQueue q
modifyTVar' queued (S.delete ref)
pure ref
drainHints :: PollerEnv -> IO ()
drainHints env@PollerEnv {peHints = ReadHints q _} = serveHints env (isEmptyTBQueue q >>= check)
passSafely :: PollerEnv -> IO ()
passSafely env = void $ safely "the pass" (runOnePass env)
hintSafely :: PollerEnv -> UTCTime -> Text -> IO ()
hintSafely env now ref = void $ safely ("the hinted read of " <> ref) (readHint env now ref)
safely :: Text -> IO () -> IO Bool
safely what action = safelyWith what False (True <$ action)
-- | Asynchronous exceptions are rethrown, since that is how the race stops this thread.
safelyWith :: Text -> a -> IO a -> IO a
safelyWith what fallback action =
try action >>= \case
Right a -> pure a
Left (e :: SomeException) -> case fromException e :: Maybe SomeAsyncException of
Just _ -> throwIO e
Nothing -> fallback <$ logError ("badge poller: " <> what <> " failed; the next pass will run: " <> tshow e)
@@ -0,0 +1,81 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
module BadgeService.Providers
( ProviderError (..),
WebhookError (..),
Received (..),
PaymentSignal (..),
ProviderInvoice (..),
Funded (..),
OrderDraft (..),
ListPass (..),
settleWindow,
expiresItself,
Provider (..),
)
where
import Data.ByteString (ByteString)
import Data.Text (Text)
import Data.Time.Clock (NominalDiffTime, UTCTime)
import Network.HTTP.Types.Header (Header)
import Simplex.Chat.PaymentService.Types (CurrencyAmount, PaymentProvider (..), ServicePaymentDestination, ServicePaymentMethod)
newtype ProviderError = ProviderError Text deriving (Eq, Show)
-- | WebhookStale is a valid signature with a timestamp too far from our clock.
data WebhookError = WebhookError Text | WebhookStale Text deriving (Eq, Show)
-- rcvAmount is the total received on the invoice so far, not the amount of one payment.
-- rcvDue is the provider's figure for what is still owed.
data Received = Received {rcvAmount :: CurrencyAmount, rcvCrypto :: Maybe Text, rcvDue :: Maybe Text}
deriving (Eq, Show)
-- The provider applies its own tolerance, so this cannot be recomputed from the amounts.
data Funded = PaidInFull | PaidInPart
deriving (Eq, Show)
data PaymentSignal
= SigFunded Received Funded
| SigSettled Received UTCTime
| SigClosed Received
deriving (Eq, Show)
data ProviderInvoice = ProviderInvoice
{ piProviderRef :: Text,
piDestination :: ServicePaymentDestination
}
deriving (Eq, Show)
-- | BTCPay invoices expire by themselves. Stripe payments stay open until we cancel them.
expiresItself :: PaymentProvider -> Bool
expiresItself = \case
PPCrypto -> True
_ -> False
-- A payment can land after the buyer's checkout window closes, so the poller keeps asking about an invoice for this long after it was created.
settleWindow :: NominalDiffTime
settleWindow = 72 * 3600
data OrderDraft = OrderDraft
{ odAmount :: CurrencyAmount,
odCurrency :: Text
}
deriving (Eq, Show)
data ListPass = ListPass
{ lpMoved :: [(Text, PaymentSignal)],
lpSkipped :: [(Maybe Text, Text)]
}
deriving (Eq, Show)
data Provider = Provider
{ pProvider :: PaymentProvider,
pCreateInvoice :: ServicePaymentMethod -> OrderDraft -> IO (Either ProviderError ProviderInvoice),
pReadInvoice :: Text -> IO (Either ProviderError (Maybe PaymentSignal)),
-- Stops the provider from accepting payment for this order.
pCancelInvoice :: Text -> IO (Either ProviderError ()),
pListOpen :: IO (Either ProviderError ListPass),
pVerifyWebhook :: UTCTime -> [Header] -> ByteString -> Either WebhookError (Maybe Text)
}
@@ -0,0 +1,519 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module BadgeService.Providers.BTCPay
( btcpayProvider,
btcMethodId,
xmrMethodId,
minorToDecimal,
paymentMethodsSignal,
listSignals,
verifyBTCPaySig,
listPageSize,
maxListPages,
)
where
import BadgeService.Config (BTCPayConfig (..), speedPolicyName)
import BadgeService.Providers
( ListPass (..),
OrderDraft (..),
PaymentSignal (..),
Provider (..),
ProviderError (..),
ProviderInvoice (..),
Funded (..),
Received (..),
settleWindow,
WebhookError (..),
)
import Control.Exception (try)
import BadgeService.Log (logError, logInfo, logWarn)
import Control.Monad (unless)
import Crypto.Hash (Digest, SHA256)
import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest)
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Aeson.Types as JT
import Data.ByteArray (constEq)
import Data.ByteArray.Encoding (Base (Base16), convertFromBase)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Lazy as LB
import Data.Char (toLower)
import Data.Int (Int64)
import Data.List (find)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Scientific (FPFormat (Fixed), Scientific, base10Exponent, formatScientific)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import Data.Word (Word32)
import Network.HTTP.Client
( HttpException,
Manager,
Request (..),
RequestBody (..),
Response (..),
brReadSome,
parseRequest,
withResponse,
)
import Network.HTTP.Client.TLS (newTlsManager)
import Network.HTTP.Types (Header, HeaderName, Method, Query, Status (..), methodGet, methodPost, renderQuery, urlEncode)
import Simplex.Chat.PaymentService.Types
( CryptoCurrency (..),
CurrencyAmount (..),
PaymentProvider (..),
ServicePaymentDestination (..),
ServicePaymentMethod (..),
)
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
btcMethodId, xmrMethodId :: Text
btcMethodId = "BTC-CHAIN"
xmrMethodId = "XMR-CHAIN"
knownMethodIds :: [Text]
knownMethodIds = [btcMethodId, xmrMethodId]
actedOnEventTypes :: [Text]
actedOnEventTypes = ["InvoiceProcessing", "InvoiceSettled", "InvoiceExpired", "InvoiceInvalid"]
maxErrorBytes :: Int64
maxErrorBytes = 4000
-- Far above any BTCPay response, far below what would exhaust the poller thread.
maxProviderBytes :: Int64
maxProviderBytes = 10 * 1024 * 1024
listPageSize :: Int
listPageSize = 100
-- Caps a server that ignores @take@, so the poller still reaches its expiry sweep.
maxListPages :: Int
maxListPages = 50
pageCapReason :: Text
pageCapReason =
"btcpay: the list stopped at "
<> tshow maxListPages
<> " pages, so any open invoice past the first "
<> tshow (maxListPages * listPageSize)
<> " was not read — and will not be read by a later pass either"
minorToDecimal :: CurrencyAmount -> Text
minorToDecimal (CurrencyAmount a) =
T.pack (show (a `div` 100)) <> "." <> T.justifyRight 2 '0' (T.pack (show (a `mod` 100)))
cryptoTarget :: ServicePaymentMethod -> Either ProviderError (Text, CryptoCurrency)
cryptoTarget = \case
SPMCrypto CCBtc -> Right (btcMethodId, CCBtc)
SPMCrypto CCXmr -> Right (xmrMethodId, CCXmr)
SPMCard _ -> Left (ProviderError "btcpay offers no card payment method")
-- BTCPay sends numbers as JSON strings; keep the exact text as sent, plus a decimal to compute with.
data WireNum = WireNum {wnText :: Text, wnValue :: Scientific}
deriving (Eq, Show)
instance J.FromJSON WireNum where
parseJSON = \case
J.String t -> case J.decodeStrict (TE.encodeUtf8 t) of
Just v | inRange v -> pure WireNum {wnText = t, wnValue = v}
Just v -> outOfRange v
Nothing -> fail ("not a decimal number: " <> show t)
J.Number v
| inRange v -> pure WireNum {wnText = T.pack (formatScientific Fixed (Just (wireDecimals v)) v), wnValue = v}
| otherwise -> outOfRange v
v -> JT.typeMismatch "numeric string" v
where
-- Check the exponent before formatting: 1e1000000000 parses but then demands a billion digits.
inRange v = base10Exponent v >= minExponent && base10Exponent v <= maxExponent
outOfRange v = fail ("decimal exponent out of range: " <> show (base10Exponent v))
-- Bounds the magnitude only, so no number demands more digits than a machine can hold.
minExponent, maxExponent :: Int
minExponent = -64
maxExponent = 64
wireDecimals :: Scientific -> Int
wireDecimals = max 0 . negate . base10Exponent
data GInvoice = GInvoice
{ giId :: Text,
giStatus :: Text,
giAdditionalStatus :: Maybe Text,
giPaymentMethods :: Maybe [GPaymentMethod]
}
deriving (Show)
instance J.FromJSON GInvoice where
parseJSON = J.withObject "invoice" $ \o ->
GInvoice
<$> o J..: "id"
<*> o J..: "status"
<*> o J..:? "additionalStatus"
<*> o J..:? "paymentMethods"
-- totalPaid is not decoded because it counts every method's payment in this currency, so it is non-zero
-- even when this method got nothing.
data GPaymentMethod = GPaymentMethod
{ gpmId :: Text,
gpmDestination :: Maybe Text,
gpmAmount :: Maybe WireNum,
gpmRate :: Maybe WireNum,
gpmPaid :: Maybe WireNum,
gpmDue :: Maybe WireNum,
gpmPayments :: [GPayment]
}
deriving (Show)
instance J.FromJSON GPaymentMethod where
parseJSON = J.withObject "payment method" $ \o ->
GPaymentMethod
<$> o J..: "paymentMethodId"
<*> o J..:? "destination"
<*> o J..:? "amount"
<*> o J..:? "rate"
<*> o J..:? "paymentMethodPaid"
<*> o J..:? "due"
<*> (fromMaybe [] <$> o J..:? "payments")
data GPayment = GPayment
{ gpStatus :: Text,
gpReceivedDate :: WireNum
}
deriving (Show)
instance J.FromJSON GPayment where
parseJSON = J.withObject "payment" $ \o ->
GPayment <$> o J..: "status" <*> o J..: "receivedDate"
newtype GCreated = GCreated {gcId :: Text}
deriving (Show)
instance J.FromJSON GCreated where
parseJSON = J.withObject "created invoice" $ \o -> GCreated <$> o J..: "id"
newtype GStoreMethod = GStoreMethod {gsmId :: Text}
deriving (Show)
instance J.FromJSON GStoreMethod where
parseJSON = J.withObject "store payment method" $ \o -> GStoreMethod <$> o J..: "paymentMethodId"
data GEvent = GEvent {geType :: Text, geInvoiceId :: Text}
deriving (Show)
instance J.FromJSON GEvent where
parseJSON = J.withObject "webhook event" $ \o -> GEvent <$> o J..: "type" <*> o J..: "invoiceId"
data BTCPayEnv = BTCPayEnv {beCfg :: BTCPayConfig, beManager :: Manager}
btcpayProvider :: BTCPayConfig -> IO Provider
btcpayProvider cfg = do
beManager <- newTlsManager
let env = BTCPayEnv {beCfg = cfg, beManager}
logStoreMethods env
pure
Provider
{ pProvider = PPCrypto,
pCreateInvoice = createInvoice env,
pReadInvoice = readInvoice env,
pCancelInvoice = cancelInvoice env,
pListOpen = listOpen env,
pVerifyWebhook = const (verifyBTCPaySig (bWebhookSecret cfg))
}
-- enabledOnly returns only enabled methods, so the boot log shows what the store can actually use.
logStoreMethods :: BTCPayEnv -> IO ()
logStoreMethods env@BTCPayEnv {beCfg} = do
r <- greenfield env what methodGet ["payment-methods"] [("enabledOnly", Just "true")] Nothing
case r >>= decodeGreenfield what of
Left (ProviderError e) -> do
logWarn ("btcpay: could not read the payment methods of store " <> bStoreId beCfg <> ": " <> e)
logInfo ("btcpay: this build offers " <> T.intercalate ", " knownMethodIds)
Right ms -> do
let enabled = map gsmId ms
missing = filter (`notElem` enabled) knownMethodIds
logInfo ("btcpay: store " <> bStoreId beCfg <> " has enabled " <> T.intercalate ", " enabled)
unless (null missing) $
logError $
"btcpay: this build offers "
<> T.intercalate ", " missing
<> ", which the store has not enabled; every checkout on those will be refused"
where
what = "read store payment methods"
createInvoice :: BTCPayEnv -> ServicePaymentMethod -> OrderDraft -> IO (Either ProviderError ProviderInvoice)
createInvoice env@BTCPayEnv {beCfg} spm OrderDraft {odAmount, odCurrency} =
case cryptoTarget spm of
Left e -> pure (Left e)
Right (methodId, cc) -> do
created <- greenfield env what methodPost ["invoices"] [] (Just (body methodId))
case created >>= decodeGreenfield what of
Left e@(ProviderError m) -> do
logWarn ("btcpay: the refused request was " <> safeDecodeUtf8 (LB.toStrict (J.encode (body methodId))) <> " -- " <> m)
pure (Left e)
Right GCreated {gcId} -> do
ms <- greenfield env what methodGet ["invoices", gcId, "payment-methods"] [] Nothing
pure (ms >>= destination methodId cc gcId)
where
what = "create invoice"
body methodId =
J.object
[ "amount" J..= minorToDecimal odAmount,
"currency" J..= T.toUpper odCurrency,
"checkout"
J..= J.object
[ -- the listener derives the row's expires_at from this same key
"expirationMinutes" J..= bExpiryMinutes beCfg,
"speedPolicy" J..= speedPolicyName (bSpeedPolicy beCfg),
"paymentTolerance" J..= bPaymentTolerance beCfg,
"paymentMethods" J..= [methodId]
]
]
destination methodId cc ref ms = do
m <- findMethod what ref methodId ms
addr <- required "destination" (gpmDestination m)
payable <- required "amount" (gpmAmount m)
pure
ProviderInvoice
{ piProviderRef = ref,
piDestination = SPDCrypto cc addr (wnText payable)
}
where
required field =
maybe (Left (ProviderError (what <> ": " <> methodId <> " on invoice " <> ref <> " has no " <> field))) Right
-- | Needs the API key's canmodifyinvoices permission, which the read and create calls do not.
cancelInvoice :: BTCPayEnv -> Text -> IO (Either ProviderError ())
cancelInvoice env ref =
fmap (fmap (const ())) $ greenfield env what methodPost ["invoices", ref, "status"] [] (Just body)
where
what = "cancel invoice"
-- Invalid, not Archive: archiving only hides the invoice, and BTCPay keeps crediting
-- payments to it
body = J.object ["status" J..= ("Invalid" :: Text)]
readInvoice :: BTCPayEnv -> Text -> IO (Either ProviderError (Maybe PaymentSignal))
readInvoice env ref = do
inv <- greenfield env what methodGet ["invoices", ref] [] Nothing
case inv >>= decodeGreenfield what of
Left e -> pure (Left e)
Right GInvoice {giStatus, giAdditionalStatus} -> do
logAdditionalStatus ref giAdditionalStatus
ms <- greenfield env what methodGet ["invoices", ref, "payment-methods"] [] Nothing
now <- getCurrentTime
pure (ms >>= paymentMethodsSignal now ref giStatus)
where
what = "read invoice"
listOpen :: BTCPayEnv -> IO (Either ProviderError ListPass)
listOpen env@BTCPayEnv {beCfg} = do
now <- getCurrentTime
let oldest = addUTCTime (negate (settleWindow + fromIntegral (60 * bExpiryMinutes beCfg))) now
fetch now (B8.pack (show (unixSeconds oldest))) 0 maxListPages (ListPass {lpMoved = [], lpSkipped = []})
where
unixSeconds :: UTCTime -> Integer
unixSeconds = floor . utcTimeToPOSIXSeconds
query startDate skip =
[ ("includePaymentMethods", Just "true"),
("startDate", Just startDate),
("take", Just (B8.pack (show listPageSize))),
("skip", Just (B8.pack (show skip)))
]
fetch :: UTCTime -> B.ByteString -> Int -> Int -> ListPass -> IO (Either ProviderError ListPass)
fetch now startDate skip pagesLeft acc
| pagesLeft <= 0 = pure (Right acc {lpSkipped = lpSkipped acc <> [(Nothing, pageCapReason)]})
| otherwise = do
listed <- greenfield env listWhat methodGet ["invoices"] (query startDate skip) Nothing
case listed >>= decodeGreenfield listWhat of
Left e -> pure (Left e)
Right invs -> case invoicesPass now invs of
Left e -> pure (Left e)
Right pass
| length invs < listPageSize -> pure (Right (merge acc pass))
| otherwise -> fetch now startDate (skip + listPageSize) (pagesLeft - 1) (merge acc pass)
merge a b = ListPass {lpMoved = lpMoved a <> lpMoved b, lpSkipped = lpSkipped a <> lpSkipped b}
listWhat :: Text
listWhat = "list invoices"
listSignals :: UTCTime -> LB.ByteString -> Either ProviderError ListPass
listSignals now body = decodeGreenfield listWhat body >>= invoicesPass now
-- When paymentMethods is absent or null the pass fails, because that means includePaymentMethods was ignored, and a
-- skip would report a healthy empty pass. Every other unreadable invoice is skipped, not failed.
invoicesPass :: UTCTime -> [J.Value] -> Either ProviderError ListPass
invoicesPass now invs = foldr add (Right ListPass {lpMoved = [], lpSkipped = []}) invs
where
add _ (Left e) = Left e
add v (Right pass) = case J.fromJSON v of
J.Error e -> Right pass {lpSkipped = (invoiceIdOf v, T.pack e) : lpSkipped pass}
J.Success gi -> addInvoice gi pass
addInvoice GInvoice {giId, giStatus, giPaymentMethods} pass = case giPaymentMethods of
Nothing ->
Left . ProviderError $
listWhat <> ": invoice " <> giId <> " carries no paymentMethods, so includePaymentMethods was not honoured"
Just ms -> case invoiceSignal now giId giStatus ms of
Left (ProviderError e) -> Right pass {lpSkipped = (Just giId, e) : lpSkipped pass}
Right Nothing -> Right pass
Right (Just sig) -> Right pass {lpMoved = (giId, sig) : lpMoved pass}
invoiceIdOf :: J.Value -> Maybe Text
invoiceIdOf v = case J.fromJSON v :: J.Result (KM.KeyMap J.Value) of
J.Success o -> case KM.lookup "id" o of
Just (J.String i) -> Just i
_ -> Nothing
J.Error _ -> Nothing
logAdditionalStatus :: Text -> Maybe Text -> IO ()
logAdditionalStatus ref = \case
Just s | s /= "None" -> logInfo ("btcpay: invoice " <> ref <> " additionalStatus " <> s)
_ -> pure ()
paymentMethodsSignal :: UTCTime -> Text -> Text -> LB.ByteString -> Either ProviderError (Maybe PaymentSignal)
paymentMethodsSignal now ref status body =
decodeGreenfield "read invoice" body >>= invoiceSignal now ref status
-- Complete is the old API's status name and never matches, so it has no case here.
invoiceSignal :: UTCTime -> Text -> Text -> [GPaymentMethod] -> Either ProviderError (Maybe PaymentSignal)
invoiceSignal now ref status ms = do
m@GPaymentMethod {gpmRate, gpmPaid, gpmDue} <- chooseMethod ref ms
rate <- priced "rate" gpmRate
paid <- priced "paymentMethodPaid" gpmPaid
let received = receivedOf rate paid gpmDue
case status of
"Settled" -> Right (Just (SigSettled received (fromMaybe now (latestSettledAt m))))
"Processing" -> Right (Just (SigFunded received PaidInFull))
"Expired" -> Right (Just (SigClosed received))
"Invalid" -> Right (Just (SigClosed received))
"New" -> Right (SigFunded received PaidInPart <$ rcvCrypto received)
other -> Left (ProviderError ("btcpay invoice " <> ref <> ": unknown status " <> other))
where
priced field =
maybe (Left (ProviderError ("btcpay invoice " <> ref <> ": its payment method carries no " <> field))) Right
chooseMethod :: Text -> [GPaymentMethod] -> Either ProviderError GPaymentMethod
chooseMethod ref ms = case filter ((`elem` knownMethodIds) . gpmId) ms of
m : _ -> Right m
[] ->
Left . ProviderError $
"btcpay invoice "
<> ref
<> ": no known payment method in ["
<> T.intercalate ", " (map gpmId ms)
<> "]; this build knows "
<> T.intercalate ", " knownMethodIds
findMethod :: Text -> Text -> Text -> LB.ByteString -> Either ProviderError GPaymentMethod
findMethod what ref methodId body = do
ms <- decodeGreenfield what body
case find ((methodId ==) . gpmId) ms of
Just m -> Right m
Nothing ->
Left . ProviderError $
what
<> ": invoice "
<> ref
<> " offers no "
<> methodId
<> ", only ["
<> T.intercalate ", " (map gpmId ms)
<> "]"
receivedOf :: WireNum -> WireNum -> Maybe WireNum -> Received
receivedOf rate paid due =
Received
{ rcvAmount = toMinorUnits (wnValue paid * wnValue rate),
rcvCrypto = if wnValue paid > 0 then Just (wnText paid) else Nothing,
rcvDue = wnText <$> due
}
-- Clamp stops a wildly wrong figure wrapping Word32 and coming out small.
toMinorUnits :: Scientific -> CurrencyAmount
toMinorUnits s = CurrencyAmount (fromInteger (max 0 (min largestAmount (round (s * 100)))))
where
largestAmount = toInteger (maxBound :: Word32)
latestSettledAt :: GPaymentMethod -> Maybe UTCTime
latestSettledAt GPaymentMethod {gpmPayments} = case mapMaybe settledAt gpmPayments of
[] -> Nothing
ts -> Just (posixSecondsToUTCTime (maximum ts))
where
settledAt GPayment {gpStatus, gpReceivedDate}
| gpStatus == "Settled" = Just (fromRational (toRational (wnValue gpReceivedDate)))
| otherwise = Nothing
sigHeaderName :: HeaderName
sigHeaderName = "BTCPay-Sig"
sigPrefix :: B.ByteString
sigPrefix = "sha256="
-- Constant-time, over the bytes as they arrived: BTCPay indents its payload, so re-encoding would not match.
verifyBTCPaySig :: Text -> [Header] -> B.ByteString -> Either WebhookError (Maybe Text)
verifyBTCPaySig secret hdrs body = do
provided <- note "missing BTCPay-Sig header" (lookup sigHeaderName hdrs)
hex <- note "BTCPay-Sig is not sha256=<hex>" (B.stripPrefix sigPrefix provided)
given <- case convertFromBase Base16 (B8.map toLower hex) of
Right (bs :: B.ByteString) -> Right bs
Left (_ :: String) -> Left (WebhookError "BTCPay-Sig is not hex")
if constEq expected given
then Right actedOn
else Left (WebhookError "BTCPay-Sig does not verify")
where
expected :: Digest SHA256
expected = hmacGetDigest (hmac (TE.encodeUtf8 secret) body :: HMAC SHA256)
note e = maybe (Left (WebhookError e)) Right
actedOn = do
GEvent {geType, geInvoiceId} <- J.decodeStrict' body
if geType `elem` actedOnEventTypes then Just geInvoiceId else Nothing
greenfield :: BTCPayEnv -> Text -> Method -> [Text] -> Query -> Maybe J.Value -> IO (Either ProviderError LB.ByteString)
greenfield BTCPayEnv {beCfg, beManager} what verb segments query body = do
r <- try $ do
req <- parseRequest (T.unpack url)
let asked =
req
{ method = verb,
requestHeaders =
[ ("Authorization", "token " <> TE.encodeUtf8 (bApiKey beCfg)),
("Accept", "application/json"),
("Content-Type", "application/json")
],
requestBody = RequestBodyLBS (maybe LB.empty J.encode body)
}
-- Read to a bound, not the whole body, so an enormous response cannot exhaust the poller thread.
withResponse asked beManager $ \resp -> do
taken <- brReadSome (responseBody resp) (fromIntegral maxProviderBytes + 1)
pure (statusCode (responseStatus resp), taken)
pure $ case r of
-- http-client hides the Authorization header when showing a Request
Left (e :: HttpException) -> Left (ProviderError (what <> " failed: " <> tshow e))
Right (code, taken)
| LB.length taken > maxProviderBytes ->
Left . ProviderError $
what <> " failed: HTTP " <> tshow code <> ", and the answer is over " <> tshow maxProviderBytes <> " bytes"
| code >= 200 && code < 300 -> Right taken
| otherwise ->
Left . ProviderError $
what <> " failed: HTTP " <> tshow code <> " " <> snippet taken
where
url =
T.dropWhileEnd (== '/') (bHost beCfg)
<> "/api/v1/stores/"
<> T.intercalate "/" (map escape (bStoreId beCfg : segments))
<> TE.decodeUtf8 (renderQuery True query)
escape = TE.decodeUtf8 . urlEncode False . TE.encodeUtf8
-- BTCPay puts the refusal reason at the end of an inline log, so keep enough bytes to reach it.
snippet = TE.decodeUtf8With (\_ _ -> Just '?') . LB.toStrict . LB.take maxErrorBytes
decodeGreenfield :: J.FromJSON a => Text -> LB.ByteString -> Either ProviderError a
decodeGreenfield what body = case J.eitherDecode' body of
Right v -> Right v
Left e -> Left (ProviderError (what <> ": could not read the response: " <> T.pack e))
@@ -0,0 +1,355 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module BadgeService.Providers.Stripe
( stripeProvider,
signalOf,
IntentRead (..),
)
where
import BadgeService.Config (StripeConfig (..))
import BadgeService.Providers
( ListPass (..),
OrderDraft (..),
PaymentSignal (..),
Provider (..),
ProviderError (..),
ProviderInvoice (..),
Received (..),
settleWindow,
WebhookError (..),
)
import Control.Exception (try)
import Control.Monad (unless)
import Crypto.Hash (Digest, SHA256)
import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest)
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import Data.ByteArray (constEq)
import Data.ByteArray.Encoding (Base (Base16), convertFromBase)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Lazy as LB
import Data.Char (toLower)
import Data.Functor ((<&>))
import Data.Int (Int64)
import Data.Maybe (isJust)
import Data.Scientific (floatingOrInteger)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import Data.Time.Clock.POSIX (POSIXTime, posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import Data.Word (Word32)
import Network.HTTP.Client
( HttpException,
Manager,
Request (..),
RequestBody (..),
Response (..),
applyBasicAuth,
brReadSome,
parseRequest,
withResponse,
)
import Network.HTTP.Client.TLS (newTlsManager)
import Network.HTTP.Types (Header, HeaderName, Method, Query, Status (..), methodGet, methodPost, renderQuery, renderSimpleQuery)
import Simplex.Chat.PaymentService.Types
( CardProvider (..),
CurrencyAmount (..),
PaymentProvider (..),
ServicePaymentDestination (..),
ServicePaymentMethod (..),
)
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
maxErrorBytes :: Int64
maxErrorBytes = 4000
-- Far above any Stripe response, far below what would exhaust the poller thread.
maxProviderBytes :: Int64
maxProviderBytes = 10 * 1024 * 1024
secondsPerMinute :: Int
secondsPerMinute = 60
listPageSize :: Int
listPageSize = 100
-- Caps a server that ignores @limit@, so the poller still reaches its expiry sweep.
maxListPages :: Int
maxListPages = 50
pageCapReason :: Text
pageCapReason =
"stripe: the list stopped at "
<> tshow maxListPages
<> " pages, so any intent past the first "
<> tshow (maxListPages * listPageSize)
<> " was not read — and will not be read by a later pass either"
untraversableReason :: Text
untraversableReason =
"stripe: the list reports more pages but the last row carries no id to page from, so any intent past this page was not read"
actedOnStripeEvents :: [Text]
actedOnStripeEvents = ["payment_intent.succeeded", "payment_intent.payment_failed", "payment_intent.canceled"]
sigHeaderName :: HeaderName
sigHeaderName = "Stripe-Signature"
listWhat :: Text
listWhat = "list intents"
-- Pin the API version so response shapes this adapter parses do not shift under it.
stripeApiVersion :: ByteString
stripeApiVersion = "2026-03-25.dahlia"
data StripeEnv = StripeEnv {seCfg :: StripeConfig, seManager :: Manager}
stripeProvider :: StripeConfig -> IO Provider
stripeProvider cfg = do
seManager <- newTlsManager
let env = StripeEnv {seCfg = cfg, seManager}
pure
Provider
{ pProvider = PPStripe,
pCreateInvoice = createInvoice env,
pReadInvoice = readInvoice env,
pCancelInvoice = cancelInvoice env,
pListOpen = listOpen env,
pVerifyWebhook = verifyStripeSig (sWebhookSecret cfg)
}
listOpen :: StripeEnv -> IO (Either ProviderError ListPass)
listOpen env@StripeEnv {seCfg} = do
now <- getCurrentTime
let oldest = addUTCTime (negate (settleWindow + fromIntegral (sSessionMinutes seCfg * secondsPerMinute))) now
createdGte = B8.pack (show (unixSeconds oldest))
go now createdGte Nothing maxListPages (ListPass [] [])
where
unixSeconds :: UTCTime -> Integer
unixSeconds = floor . utcTimeToPOSIXSeconds
go now createdGte after pagesLeft acc
| pagesLeft <= 0 = pure (Right acc {lpSkipped = lpSkipped acc <> [(Nothing, pageCapReason)]})
| otherwise = do
let q =
[("created[gte]", Just createdGte), ("limit", Just (B8.pack (show listPageSize)))]
<> maybe [] (\a -> [("starting_after", Just (TE.encodeUtf8 a))]) after
got <- stripeApi env listWhat methodGet ["v1", "payment_intents"] q Nothing
case got >>= decodeStripe listWhat of
Left e -> pure (Left e)
Right IntentList {ilData, ilHasMore} ->
let acc' = merge acc (intentsPass now ilData)
in if ilHasMore
then case lastId ilData of
Just next -> go now createdGte (Just next) (pagesLeft - 1) acc'
Nothing -> pure (Right acc' {lpSkipped = lpSkipped acc' <> [(Nothing, untraversableReason)]})
else pure (Right acc')
merge a b = ListPass {lpMoved = lpMoved a <> lpMoved b, lpSkipped = lpSkipped a <> lpSkipped b}
intentsPass :: UTCTime -> [J.Value] -> ListPass
intentsPass now = foldr add (ListPass [] [])
where
add v pass = case J.fromJSON v :: J.Result IntentRead of
J.Error e -> pass {lpSkipped = (intentIdOf v, T.pack e) : lpSkipped pass}
J.Success ir -> case signalOf now ir of
Left (ProviderError e) -> pass {lpSkipped = (Just (irId ir), e) : lpSkipped pass}
Right Nothing -> pass
Right (Just sig) -> pass {lpMoved = (irId ir, sig) : lpMoved pass}
intentIdOf :: J.Value -> Maybe Text
intentIdOf v = case J.fromJSON v :: J.Result (KM.KeyMap J.Value) of
J.Success o -> case KM.lookup "id" o of
Just (J.String i) -> Just i
_ -> Nothing
J.Error _ -> Nothing
lastId :: [J.Value] -> Maybe Text
lastId vs = case vs of
[] -> Nothing
_ -> intentIdOf (last vs)
data IntentList = IntentList {ilData :: [J.Value], ilHasMore :: Bool}
instance J.FromJSON IntentList where
parseJSON = J.withObject "intent list" $ \o ->
IntentList <$> o J..: "data" <*> o J..:? "has_more" J..!= False
-- | Stripe signs each retry again with the current time, so an old timestamp means a replayed request.
webhookTolerance :: NominalDiffTime
webhookTolerance = 15 * 60
-- Constant-time over the raw bytes. Stripe signs "{t}.{body}", so hash the timestamp, a dot, then the body as it arrived.
verifyStripeSig :: Text -> UTCTime -> [Header] -> ByteString -> Either WebhookError (Maybe Text)
verifyStripeSig secret now hdrs body = do
raw <- note "missing Stripe-Signature header" (lookup sigHeaderName hdrs)
(t, v1hexes) <- note "Stripe-Signature is not t=..,v1=.." (parseStripeSig raw)
givens <- mapM decodeHex v1hexes
let expected :: Digest SHA256
expected = hmacGetDigest (hmac (TE.encodeUtf8 secret) (t <> "." <> body) :: HMAC SHA256)
-- during signing-secret rotation Stripe sends one v1 per active secret; any match verifies
unless (any (constEq expected) givens) $ Left (WebhookError "Stripe-Signature does not verify")
signedAt <- note "Stripe-Signature t is not a whole number of seconds" (fullInt t)
if abs (diffUTCTime now (posixSecondsToUTCTime (fromIntegral signedAt))) <= webhookTolerance
then Right actedOn
else Left (WebhookStale ("Stripe-Signature t is more than " <> tshow (round webhookTolerance `div` 60 :: Int) <> " minutes from now; check the server clock"))
where
fullInt s = case B8.readInt s of
Just (n, rest) | B8.null rest -> Just n
_ -> Nothing
note e = maybe (Left (WebhookError e)) Right
decodeHex v1hex = case convertFromBase Base16 (B8.map toLower v1hex) of
Right (bs :: ByteString) -> Right bs
Left (_ :: String) -> Left (WebhookError "Stripe-Signature v1 is not hex")
actedOn = do
SEvent {seType, seRef} <- J.decodeStrict' body
if seType `elem` actedOnStripeEvents then Just seRef else Nothing
parseStripeSig :: ByteString -> Maybe (ByteString, [ByteString])
parseStripeSig raw = do
let pairs = [(k, B8.drop 1 rest) | part <- B8.split ',' raw, let (k, rest) = B8.break (== '=') part, not (B8.null rest)]
t <- lookup "t" pairs
case [v | ("v1", v) <- pairs] of
[] -> Nothing
v1s -> Just (t, v1s)
data SEvent = SEvent {seType :: Text, seRef :: Text}
instance J.FromJSON SEvent where
parseJSON = J.withObject "stripe event" $ \o -> do
seType <- o J..: "type"
dataO <- o J..: "data"
obj <- dataO J..: "object"
seRef <- obj J..: "id"
pure SEvent {seType, seRef}
data CreatedIntent = CreatedIntent {ciId :: Text, ciClientSecret :: Text}
instance J.FromJSON CreatedIntent where
parseJSON = J.withObject "payment_intent" $ \o ->
CreatedIntent <$> o J..: "id" <*> o J..: "client_secret"
createInvoice :: StripeEnv -> ServicePaymentMethod -> OrderDraft -> IO (Either ProviderError ProviderInvoice)
createInvoice _ (SPMCrypto _) _ = pure (Left (ProviderError "stripe offers no crypto payment method"))
createInvoice env (SPMCard CPStripe) OrderDraft {odAmount = CurrencyAmount minor, odCurrency} = do
let form =
[ ("amount", B8.pack (show minor)),
("currency", TE.encodeUtf8 (T.toLower odCurrency)),
-- Only card is offered, because a redirect-based method would navigate the top window out of the embedded frame.
("allowed_payment_method_types[]", "card")
]
created <- stripeApi env what methodPost ["v1", "payment_intents"] [] (Just form)
pure $ created >>= decodeStripe what <&> \CreatedIntent {ciId, ciClientSecret} ->
ProviderInvoice {piProviderRef = ciId, piDestination = SPDCard CPStripe ciClientSecret}
where
what = "create intent"
cancelInvoice :: StripeEnv -> Text -> IO (Either ProviderError ())
cancelInvoice env pid =
fmap (fmap (const ())) $ stripeApi env "cancel intent" methodPost ["v1", "payment_intents", pid, "cancel"] [] (Just [])
readInvoice :: StripeEnv -> Text -> IO (Either ProviderError (Maybe PaymentSignal))
readInvoice env pid = do
now <- getCurrentTime
got <- stripeApi env what methodGet ["v1", "payment_intents", pid] [("expand[]", Just "latest_charge")] Nothing
pure $ got >>= decodeStripe what >>= signalOf now
where
what = "read intent " <> pid
signalOf :: UTCTime -> IntentRead -> Either ProviderError (Maybe PaymentSignal)
signalOf now IntentRead {irStatus, irAmountReceived, irChargeCreated} =
case irStatus of
"succeeded" -> Right (Just (SigSettled (received irAmountReceived) settledAt))
"canceled" -> Right (Just (SigClosed (received 0)))
s | s `elem` openStatuses -> Right Nothing
| otherwise -> Left (ProviderError ("stripe payment_intent: unknown status " <> s))
where
openStatuses = ["requires_payment_method", "requires_confirmation", "requires_action", "processing", "requires_capture"]
received amt = Received {rcvAmount = amountFrom amt, rcvCrypto = Nothing, rcvDue = Nothing}
settledAt = maybe now posixSecondsToUTCTime irChargeCreated
-- Clamp stops a wildly wrong figure wrapping Word32 and coming out small.
amountFrom :: Int64 -> CurrencyAmount
amountFrom n = CurrencyAmount (fromInteger (max 0 (min largestAmount (toInteger n))))
where
largestAmount = toInteger (maxBound :: Word32)
data IntentRead = IntentRead
{ irId :: Text,
irStatus :: Text,
irAmountReceived :: Int64,
irChargeCreated :: Maybe POSIXTime
}
instance J.FromJSON IntentRead where
parseJSON = J.withObject "payment_intent" $ \o -> do
irId <- o J..: "id"
irStatus <- o J..: "status"
irAmountReceived <- o J..:? "amount_received" J..!= 0
-- expand[]=latest_charge makes this the charge object; unexpanded it is a string id we ignore
latest <- o J..:? "latest_charge"
let irChargeCreated = fmap fromInteger (chargeCreated latest)
pure IntentRead {irId, irStatus, irAmountReceived, irChargeCreated}
chargeCreated :: Maybe J.Value -> Maybe Integer
chargeCreated = \case
Just (J.Object c) -> KM.lookup "created" c >>= asInteger
_ -> Nothing
asInteger :: J.Value -> Maybe Integer
asInteger = \case
J.Number n -> either (const Nothing) Just (floatingOrInteger n :: Either Double Integer)
_ -> Nothing
stripeApi ::
StripeEnv ->
Text ->
Method ->
[Text] ->
Query ->
Maybe [(ByteString, ByteString)] ->
IO (Either ProviderError LB.ByteString)
stripeApi StripeEnv {seCfg, seManager} what verb segments query form = do
r <- try $ do
req0 <- parseRequest (T.unpack url)
-- applyBasicAuth must wrap the record update, because updating requestHeaders after it would drop the header it adds.
let req =
applyBasicAuth (TE.encodeUtf8 (sSecretKey seCfg)) "" $
req0
{ method = verb,
requestHeaders =
("Accept", "application/json")
: ("Stripe-Version", stripeApiVersion)
: [("Content-Type", "application/x-www-form-urlencoded") | isJust form],
requestBody = RequestBodyBS (maybe "" (renderSimpleQuery False) form)
}
withResponse req seManager $ \resp -> do
taken <- brReadSome (responseBody resp) (fromIntegral maxProviderBytes + 1)
pure (statusCode (responseStatus resp), taken)
pure $ case r of
-- http-client hides the Authorization header when showing a Request
Left (e :: HttpException) -> Left (ProviderError (what <> " failed: " <> tshow e))
Right (code, taken)
| LB.length taken > maxProviderBytes ->
Left . ProviderError $
what <> " failed: HTTP " <> tshow code <> ", and the answer is over " <> tshow maxProviderBytes <> " bytes"
| code >= 200 && code < 300 -> Right taken
| otherwise ->
Left . ProviderError $
what <> " failed: HTTP " <> tshow code <> " " <> snippet taken
where
url =
T.dropWhileEnd (== '/') (sHost seCfg)
<> "/"
<> T.intercalate "/" segments
<> TE.decodeUtf8 (renderQuery True query)
snippet = safeDecodeUtf8 . LB.toStrict . LB.take maxErrorBytes
decodeStripe :: J.FromJSON a => Text -> LB.ByteString -> Either ProviderError a
decodeStripe what body = case J.eitherDecode' body of
Right v -> Right v
Left e -> Left (ProviderError (what <> ": could not read the response: " <> T.pack e))
@@ -1,4 +1,6 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -18,23 +20,32 @@ module BadgeService.Service
)
where
import BadgeService.Catalog (defaultCatalog)
import BadgeService.Config (BadgeIssuerKey (..), ServiceConfig (..), readServiceConfig)
import BadgeService.Options
import BadgeService.Poller (newPollerEnv, newReadHints, runPoller)
import BadgeService.Providers.BTCPay (btcpayProvider)
import BadgeService.Providers.Stripe (stripeProvider)
import BadgeService.Store
import BadgeService.Store.Invoices (seedCatalog, truncateToSecond)
import BadgeService.Store.Migrate (runBadgeServiceMigrations)
import BadgeService.Waiters (Waiters, newWaiters)
import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener)
import Control.Applicative (optional)
import Control.Concurrent.STM
import Control.Logger.Simple
import BadgeService.Log (logError, logInfo, logWarn)
import Control.Monad
import Control.Monad.IO.Class (liftIO)
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Char (isSpace)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.Maybe (fromMaybe, maybeToList)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, maybeToList)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Word (Word32)
@@ -43,32 +54,37 @@ 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')
import Simplex.Chat.Bot (initializeBotAddress', sendMessage)
import Simplex.Chat.Bot.Store (withDB, withDB')
import Simplex.Chat.Controller
import Simplex.Chat.Core (sendChatCmd, simplexChatCore)
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent (CIContent (..), SMsgDirection (..), ciContentToText)
import Simplex.Chat.Options (printDbOpts)
import Simplex.Chat.Terminal (terminalChatConfig)
import Simplex.Chat.Terminal.Main (simplexChatCLI')
import Simplex.Chat.Types (AgentInvId (..), User (..))
import Simplex.Chat.Types (AgentInvId (..), Contact, User (..))
import Simplex.Messaging.Agent.Store.Common (DBStore)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (bbsPublicKey)
import Simplex.Messaging.Encoding.String (TextEncoding, strEncode, textDecode)
import Simplex.Messaging.Version (isCompatible)
import Simplex.Messaging.Encoding.String (TextEncoding, strEncode, textDecode, textEncode)
import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow)
import Simplex.Messaging.Version (isCompatible)
import System.Directory (getAppUserDataDirectory)
import System.Exit (exitFailure)
data ServiceState = ServiceState
{ serviceCC :: TMVar ChatController,
serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object)
serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object),
chatRedeemQ :: TQueue (Contact, T.Text)
}
newServiceState :: IO ServiceState
newServiceState = do
serviceCC <- newEmptyTMVarIO
serviceRequestQ <- newTQueueIO
pure ServiceState {serviceCC, serviceRequestQ}
chatRedeemQ <- newTQueueIO
pure ServiceState {serviceCC, serviceRequestQ, chatRedeemQ}
welcomeGetOpts :: IO BadgeServiceOpts
welcomeGetOpts = do
@@ -80,45 +96,88 @@ welcomeGetOpts = do
putStrLn $ "Service name: " ++ T.unpack serviceName
pure opts
-- | Check the secret is the key trusted at its index: otherwise every code redeemed is burned.
checkIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO (Either String BadgeIssuerKey)
checkIssuerKey BadgeServiceOpts {issuerKey} ChatConfig {badgePublicKeys} = case issuerKey of
Nothing -> pure $ Left "an issuer key is required - pass both --issuer-key-idx and --issuer-secret (see `simplex-chat badge keygen`)"
Just k@BadgeIssuerKey {keyIdx, secretKey} ->
bbsPublicKey secretKey >>= \case
Left e -> pure $ Left $ "issuer secret is not a valid key: " <> e
Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of
Just pk' | pk' == pk -> Right k
Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk)
Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs"
checkIssuerKey :: BadgeServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO (Either String BadgeIssuerKey)
checkIssuerKey BadgeServiceOpts {issuerKey} serviceCfg cfg = case issuerKey of
Left e -> pure (Left e)
Right (Just k) -> checkOne cfg k
Right Nothing -> case serviceCfg >>= issuer of
Nothing -> pure $ Left "an issuer key is required - pass --issuer-key-idx and --issuer-secret, or add an [issuer] section to badge_service.ini (see `simplex-chat badge keygen`)"
Just k -> checkOne cfg k
requireIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO BadgeIssuerKey
requireIssuerKey opts cfg =
checkIssuerKey opts cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure
checkOne :: ChatConfig -> BadgeIssuerKey -> IO (Either String BadgeIssuerKey)
checkOne ChatConfig {badgePublicKeys} k@BadgeIssuerKey {keyIdx, secretKey} =
bbsPublicKey secretKey >>= \case
Left e -> pure $ Left $ "issuer secret at index " <> show keyIdx <> " is not a valid key: " <> e
Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of
Just pk' | pk' == pk -> Right k
Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk)
Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs"
requireIssuerKey :: BadgeServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO BadgeIssuerKey
requireIssuerKey opts serviceCfg cfg =
checkIssuerKey opts serviceCfg cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure
readConfigOrExit :: FilePath -> IO ServiceConfig
readConfigOrExit path =
readServiceConfig path >>= \case
Left e -> putStrLn (path <> ": " <> e) >> exitFailure
Right sc -> pure sc
badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO ()
badgeService opts cfg env = do
key <- requireIssuerKey opts cfg
let chatHooks =
badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do
serviceCfg <- traverse readConfigOrExit serviceConfigFile
key <- requireIssuerKey opts serviceCfg cfg
waiters <- newWaiters
let devRedeem = maybe False devChatRedeem serviceCfg
chatHooks =
defaultChatHooks
{ preStartHook = Just $ badgePreStartHook opts,
postStartHook = Just $ badgePostStartHook opts env,
postStartHook = Just $ badgePostStartHook opts devRedeem env,
preCmdHook = Just badgeCmdHook
}
-- the reader must not block: outputQ carries every chat event
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc ->
raceAny_
when devRedeem $ logWarn "[dev] chat_redeem is on: /redeem over chat hands out credentials this service can link"
-- The reader must not block, since outputQ carries every chat event.
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> do
lanes <- maybe (pure []) (serviceLanes waiters cc) serviceCfg
raceAny_ $
[ forever $
atomically (readTBQueue $ outputQ cc) >>= \case
(_, Right (CEvtServiceRequest u reqId sigKey reqData)) ->
atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData)
(_, Right CEvtNewChatItems {chatItems = AChatItem _ SMDRcv (DirectChat ct) ChatItem {content = mc@CIRcvMsgContent {}} : _})
| devRedeem -> atomically $ writeTQueue (chatRedeemQ env) (ct, ciContentToText mc)
_ -> pure (),
processQueuedRequests key env
]
<> [processChatRedeems key env | devRedeem]
<> lanes
where
serviceLanes :: Waiters -> ChatController -> ServiceConfig -> IO [IO ()]
serviceLanes ws ChatController {chatStore} sc = do
-- Seed before the listener accepts anything, since every checkout is priced from these.
seedServiceCatalog chatStore
btc <- maybe (pure []) (fmap (: []) . btcpayProvider) (btcpay sc)
str <- maybe (pure []) (fmap (: []) . stripeProvider) (stripe sc)
let providers = btc <> str
hints <- newReadHints
webEnv <- newWebEnv chatStore sc ws hints providers
exportWebapp (listener sc) (stripe sc)
pollerEnv <- newPollerEnv chatStore ws hints providers (poll sc)
pure [runWebListener webEnv, runPoller pollerEnv]
seedServiceCatalog :: DBStore -> IO ()
seedServiceCatalog st = do
now <- truncateToSecond <$> getCurrentTime
let (prices, offers) = defaultCatalog now
(seededPrices, seededOffers) <- seedCatalog st prices offers
logInfo $
"badge catalog: " <> tshow (length prices) <> " prices and " <> tshow (length offers) <> " offers compiled in, "
<> tshow seededPrices <> " prices and " <> tshow seededOffers <> " offers inserted"
badgeServiceCLI :: BadgeServiceOpts -> IO ()
badgeServiceCLI opts = do
key <- requireIssuerKey opts terminalChatConfig
badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do
serviceCfg <- traverse readConfigOrExit serviceConfigFile
key <- requireIssuerKey opts serviceCfg terminalChatConfig
env <- newServiceState
let eventHook _cc ev = do
case ev of
@@ -129,7 +188,7 @@ badgeServiceCLI opts = do
chatHooks =
defaultChatHooks
{ preStartHook = Just $ badgePreStartHook opts,
postStartHook = Just $ badgePostStartHook opts env,
postStartHook = Just $ badgePostStartHook opts False env,
preCmdHook = Just badgeCmdHook,
eventHook = Just eventHook
}
@@ -138,34 +197,51 @@ badgeServiceCLI opts = do
processQueuedRequests key env
]
-- | issuing codes lives here rather than in core: every user's app would otherwise ship it
badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand)
badgeCmdHook cc = \case
CustomChatCommand cmd -> Left <$> runBadgeCmd cc cmd
cmd -> pure $ Right cmd
runBadgeCmd :: ChatController -> ByteString -> IO (Either ChatError ChatResponse)
runBadgeCmd cc cmd = case A.parseOnly issueCmdP cmd of
Left _ -> pure $ chatCmdError "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free]"
Right issueOpts ->
issueBadgeCode cc issueOpts >>= \case
Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code}
Left e -> pure $ chatCmdError $ "issuing code: " <> e
runBadgeCmd cc cmd
| Right issueOpts <- A.parseOnly issueCmdP cmd =
issueBadgeCode cc issueOpts >>= \case
Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code}
Left e -> pure $ chatCmdError $ "issuing code: " <> e
| Right code <- A.parseOnly revokeCmdP cmd =
revokeBadgeCode cc code >>= \case
Right Revoked -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "revoked"}
Right AlreadyRevoked -> pure $ chatCmdError "code was revoked already"
Right AlreadyRedeemed -> pure $ chatCmdError "code was redeemed already, so it cannot be revoked"
Right NoSuchCode -> pure $ chatCmdError "no such code"
Left e -> pure $ chatCmdError $ "revoking code: " <> e
| otherwise = pure $ chatCmdError "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], or //revoke <code>"
revokeCmdP :: A.Parser BadgeCode
revokeCmdP =
"revoke " *> (A.takeWhile1 (not . isSpace) >>= maybe (fail "not a badge code") pure . parseBadgeCode . safeDecodeUtf8)
<* (A.skipSpace *> A.endOfInput)
revokeBadgeCode :: ChatController -> BadgeCode -> IO (Either String RevokeResult)
revokeBadgeCode cc code = do
now <- truncateToSecond <$> getCurrentTime
withDB' "revokeBadgeCode" cc $ \db -> revokeCode db (badgeCodeHash code) now
issueCmdP :: A.Parser IssueCodeOpts
issueCmdP =
"issue " *> do
badgeType <- badgeTypeP
months_ <- optional (A.space *> A.decimal)
-- outside `optional`, which would otherwise backtrack past a bad count
months_ <- optional (A.space *> (A.decimal :: A.Parser Integer))
-- Kept outside optional, which would otherwise backtrack past a bad count.
months <- maybe (pure 1) checkMonths months_
paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP)
A.skipSpace
A.endOfInput
pure IssueCodeOpts {badgeType, months, paymentStatus}
where
-- Integer, because attoparsec's decimal wraps silently at Int, so the guard would check a truncated count.
checkMonths n
| n >= 1 && n <= (255 :: Int) = pure n
| n >= 1 && n <= 255 = pure (fromInteger n)
| otherwise = fail "months must be between 1 and 255"
-- BadgeType decodes anything to BTUnknown, so a typo would issue an unusable code
badgeTypeP =
@@ -198,19 +274,38 @@ processQueuedRequests key env = do
(u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env
handleServiceRequest key cc u reqId sigKey reqData
processChatRedeems :: BadgeIssuerKey -> ServiceState -> IO ()
processChatRedeems key env = do
cc <- atomically $ readTMVar $ serviceCC env
forever $ do
(ct, msg) <- atomically $ readTQueue $ chatRedeemQ env
chatRedeem key cc ct msg
-- | Here the service generates the master key and can link the badge, so [dev] chat_redeem gates this.
chatRedeem :: BadgeIssuerKey -> ChatController -> Contact -> T.Text -> IO ()
chatRedeem key cc ct msg = case T.stripPrefix "/redeem" (T.strip msg) of
Just rest | not (T.null (T.strip rest)) -> do
masterKey <- generateMasterKey (random cc)
(purchaseKey, _) <- atomically $ C.generateKeyPair (random cc) :: IO (C.KeyPair 'C.Ed25519)
resp <- redeemCode key cc purchaseKey masterKey (T.strip rest)
sendMessage cc ct $ case resp of
BSPBadgeCredential {credential = Just cred} -> safeDecodeUtf8 $ LB.toStrict $ J.encode cred
BSPError {code} -> "error: " <> textEncode code
_ -> "unexpected response"
_ -> sendMessage cc ct "send: /redeem <code>"
badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO ()
badgePreStartHook opts ChatController {config, chatStore} =
runBadgeServiceMigrations opts config chatStore
badgePostStartHook :: BadgeServiceOpts -> ServiceState -> ChatController -> IO ()
badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do
-- SREQ delivery gates on this flag; Core starts serviceRequests=False, so the hook must set it.
badgePostStartHook :: BadgeServiceOpts -> Bool -> ServiceState -> ChatController -> IO ()
badgePostStartHook BadgeServiceOpts {noAddress, testing} devRedeem env cc = do
-- Core starts this False and gates service request delivery on it, so the hook must set it.
atomically $ writeTVar (processServiceRequests cc) True
readTVarIO (currentUser cc) >>= \case
Nothing -> putStrLn "No current user" >> exitFailure
-- DR required for service RPC; autoAccept off because badge service ignores contact events.
Just _ -> do
unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc
unless noAddress $ initializeBotAddress' (not testing) (Just True) devRedeem cc
void $ atomically $ tryPutTMVar (serviceCC env) cc
handleServiceRequest :: BadgeIssuerKey -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO ()
@@ -230,8 +325,6 @@ responseObject r = case J.toJSON r of
errorResponse :: BadgeServiceErrorCode -> BadgeServiceResponse
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
@@ -240,8 +333,7 @@ badgeErrorRetryAfter = \case
_ -> Nothing
-- | The agent verified the signature, so sigKey is a key the sender holds - a purchaseKey that
-- differs would let a client claim a purchase it cannot sign for.
-- | The agent verified the signature, so sigKey is a key the sender holds; a differing purchaseKey would let a client claim a purchase it cannot sign for.
badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse
badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) of
J.Error _ -> pure $ errorResponse BSEBadRequest
@@ -255,8 +347,7 @@ badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData)
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
-- 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
Nothing -> pure $ errorResponse BSEUnsupportedVersion
Just k ->
@@ -265,21 +356,18 @@ 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.
-- | Neither the badge type nor the expiry comes from the caller; both derive from the entry.
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)
@@ -287,51 +375,58 @@ credentialResponse :: Maybe BadgeCredential -> Maybe T.Text -> [StatementEntry]
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.
-- | Nothing is written until the credential is signed, so a signing failure leaves the code unspent.
redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse
redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText of
Nothing -> pure $ errorResponse BSECodeInvalid
Just code ->
withDB "getBadgeCode" cc (readCode code) >>= \case
Just code -> do
now <- badgeNow cc
withDB "getBadgeCode" cc (readCode now code) >>= \case
Left _ -> pure $ errorResponse BSEInternal
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
-- 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 signed -> do
-- re-read: a concurrent redemption may have landed while this one was signing
-- If the code was revoked or redeemed while signing, the claim fails. Read the code again to tell the client why.
r <- withDB "writeCodeRedemption" cc $ \db ->
readCode code db >>= \case
Left resp -> pure resp
Right _ -> liftIO $ do
purchaseId <- createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now
liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case
Nothing ->
readCode now code db >>= \case
Left resp -> pure resp
Right _ -> logError "badge service: redeeming a code failed, but the code is neither redeemed nor revoked" $> errorResponse BSEInternal
Just purchaseId -> liftIO $ do
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
-- used before signing and again inside the write transaction; every Left is a finished
-- response, an unknown code included
readCode code db = liftIO $
readCode now code db = liftIO $
getBadgeCode db (badgeCodeHash code) >>= \case
Nothing -> pure $ Left $ errorResponse BSECodeInvalid
Just c@IssuedCode {redemption} -> fmap (const c) <$> checkUnspent db redemption
Just c@IssuedCode {revokedAt, paymentStatus, expiresAt, redemption}
-- Revoked is checked first, so it answers as if the code never existed.
| Just _ <- revokedAt -> pure $ Left $ errorResponse BSECodeInvalid
-- Redeeming an unpaid code would issue a free badge, so unpaid is refused.
| CPSUnpaid <- paymentStatus -> pure $ Left $ errorResponse BSEPaymentPending
| otherwise ->
checkUnspent db redemption >>= \case
Left resp -> pure $ Left resp
Right ()
| maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired
| otherwise -> pure $ Right c
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
@@ -359,21 +454,19 @@ issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do
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
-- Write only if the tip has not moved, since another request may have advanced it.
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
-- single opening credit, 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_
@@ -21,9 +21,12 @@ module BadgeService.Store
appendLedgerPlan,
createCodePurchase,
insertBadgeCode,
RevokeResult (..),
revokeCode,
)
where
import BadgeService.Store.Invoices (executeChanging)
import qualified Data.Aeson as J
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
@@ -52,11 +55,12 @@ data IssuedCode = IssuedCode
{ badgeCodeId :: Int64,
badgeType :: BadgeType,
months :: Int,
paymentStatus :: BadgeCodePaymentStatus,
revokedAt :: Maybe UTCTime,
expiresAt :: Maybe UTCTime,
redemption :: CodeRedemption
}
-- A code that has a purchase is spent, even if its credential cannot be read. Treating that as
-- an unredeemed code would issue a second credential for it.
data CodeRedemption
= CodeUnredeemed
| CodeRedeemed RedeemedCode
@@ -68,8 +72,6 @@ data RedeemedCode = RedeemedCode
credential :: BadgeCredential
}
-- 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,
purchaseKey :: C.PublicKeyEd25519,
@@ -89,7 +91,8 @@ getBadgeCode db codeHash =
DB.query
db
[sql|
SELECT c.badge_code_id, c.badge_type, c.months, p.badge_purchase_id, p.purchase_key, i.credential
SELECT c.badge_code_id, c.badge_type, c.months, c.code_payment_status, c.revoked_at,
c.expires_at, 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
@@ -99,8 +102,8 @@ getBadgeCode db codeHash =
|]
(Only (Binary codeHash))
where
toCode (badgeCodeId, badgeType, months, purchaseId_, purchaseKey_, credential_) =
IssuedCode {badgeCodeId, badgeType, months, redemption = codeRedemption purchaseId_ purchaseKey_ credential_}
toCode (badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, purchaseId_, purchaseKey_, credential_) =
IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, 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}
@@ -113,7 +116,6 @@ 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)
-- | 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 $
@@ -144,8 +146,6 @@ getLedgerTip db purchaseId =
|]
(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 $
@@ -154,8 +154,7 @@ getLedgerEntryId db purchaseId entryUuid =
"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.
-- | 0 returns the whole ledger, as entry_id starts at 1.
getLedgerEntries :: DB.Connection -> Int64 -> Int64 -> IO (Maybe [StatementEntry])
getLedgerEntries db purchaseId afterEntryId =
mapM toEntry
@@ -175,7 +174,6 @@ toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs,
(\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 <-
@@ -192,10 +190,7 @@ getCurrentIssuance db purchaseId now = do
[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.
-- TODO write the reference columns (payment_id, charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today.
appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO ()
appendLedgerPlan db purchaseId rows issuance_ = do
mapM_ appendRow rows
@@ -210,8 +205,6 @@ appendLedgerPlan db purchaseId rows issuance_ = do
(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)
)
@@ -229,21 +222,48 @@ appendLedgerPlan db purchaseId rows issuance_ = do
((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
-- redeemed_at is stamped here, so this must run in the same transaction as the credential rows.
-- Mark the code as redeemed before adding the purchase. On Postgres, a revoke or redemption running
-- at the same time then waits, sees the code is taken, and fails.
createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO (Maybe Int64)
createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_purchases
(purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now)
purchaseId <- insertedRowId db
DB.execute db "UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ?" (now, badgeCodeId)
pure purchaseId
claimed <-
executeChanging
db
"UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ? AND redeemed_at IS NULL AND revoked_at IS NULL"
(now, badgeCodeId)
if claimed == 0
then pure Nothing
else do
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_purchases
(purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now)
Just <$> insertedRowId db
data RevokeResult = Revoked | AlreadyRevoked | AlreadyRedeemed | NoSuchCode
deriving (Eq, Show)
-- | A code that was already redeemed can't be revoked, because its badge was already given out.
revokeCode :: DB.Connection -> ByteString -> UTCTime -> IO RevokeResult
revokeCode db codeHash now = do
revoked <-
executeChanging
db
"UPDATE sx_badge_service_badge_codes SET revoked_at = ? WHERE code_hash = ? AND revoked_at IS NULL AND redeemed_at IS NULL"
(now, Binary codeHash)
if revoked > 0
then pure Revoked
else
maybeFirstRow' NoSuchCode refusal $
DB.query db "SELECT revoked_at FROM sx_badge_service_badge_codes WHERE code_hash = ?" (Only (Binary codeHash))
where
refusal :: Only (Maybe UTCTime) -> RevokeResult
refusal (Only revokedAt) = maybe AlreadyRedeemed (const AlreadyRevoked) revokedAt
insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO ()
insertBadgeCode db codeHash badgeType months paymentStatus now =
@@ -0,0 +1,613 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeOperators #-}
module BadgeService.Store.Invoices
( InvoiceRow (..),
InvoicePayment (..),
paymentHolds,
NewInvoice (..),
CreateError (..),
StoreDecodeError (..),
createInvoiceRows,
executeChanging,
getInvoice,
getInvoiceByProviderRef,
unpaidRefs,
providerText,
codeHashExists,
OverdueInvoice (..),
overdueInvoices,
expireOverdue,
cancelOpenInvoice,
newInvoiceId,
readCatalogRows,
seedCatalog,
truncateToSecond,
settlementInvoice,
settlementCodeHash,
upsertPayment,
paymentStatusText,
cryptoCurrencyText,
textToInvoiceStatus,
invoiceStatusText,
updateInvoiceStatus,
markCodePaid,
)
where
import Control.Exception (Exception)
import qualified Control.Exception as E
import Control.Monad (filterM)
import Crypto.Random (getRandomBytes)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Base64.URL as B64U
import qualified Data.ByteString.Char8 as BC8
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import Data.Word (Word8, Word32)
import Simplex.Chat.Badges (BadgeType (..))
import Simplex.Chat.Badges.Service (BadgePrice (..), BadgeOffer (..))
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..), BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), PaymentStatus (..), ServicePaymentDestination (..))
import Simplex.Messaging.Agent.Store.Common (DBStore, withConnection, withTransaction)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Encoding.String (textEncode)
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
#if defined(dbPostgres)
import BadgeService.Store.Postgres.Migrations (servicePrefix, withPrefix)
import Database.PostgreSQL.Simple (Only (..), Query, ToRow, (:.) (..))
import qualified Database.PostgreSQL.Simple as PSQL
import Database.PostgreSQL.Simple.Errors (ConstraintViolation (..), constraintViolation)
#else
import BadgeService.Store.SQLite.Migrations (servicePrefix, withPrefix)
import Database.SQLite.Simple (Only (..), Query, ToRow, (:.) (..))
import qualified Database.SQLite.Simple as SQL
#endif
data InvoiceRow = InvoiceRow
{ irInvoiceId :: InvoiceId,
irProvider :: PaymentProvider,
irProviderRef :: Text,
irBadgeType :: BadgeType,
irMonths :: Word8,
irPrice :: CurrencyAmount,
irAmount :: CurrencyAmount,
irCurrency :: Text,
irDestination :: ServicePaymentDestination,
irExpiresAt :: UTCTime,
irStatus :: InvoiceStatus,
irCreatedAt :: UTCTime,
irPayment :: Maybe InvoicePayment
}
deriving (Eq, Show)
paymentHolds :: InvoicePayment -> Bool
paymentHolds InvoicePayment {ipAmount, ipCryptoPaid, ipPaidInFull} =
maybe False (\(CurrencyAmount a) -> a > 0) ipAmount || ipCryptoPaid /= Nothing || ipPaidInFull
data InvoicePayment = InvoicePayment
{ ipAmount :: Maybe CurrencyAmount,
ipCryptoPaid :: Maybe Text,
ipCryptoDue :: Maybe Text,
ipPaidInFull :: Bool,
ipStatus :: Text,
ipUpdatedAt :: UTCTime
}
deriving (Eq, Show)
data NewInvoice = NewInvoice
{ niInvoiceId :: InvoiceId,
niProviderRef :: Text,
niCodeHash :: ByteString,
niPriceId :: BadgePriceId,
niOfferId :: Maybe BadgeOfferId,
niBadgeType :: BadgeType,
niMonths :: Word8,
niPrice :: CurrencyAmount,
niAmount :: CurrencyAmount,
niCurrency :: Text,
niProvider :: PaymentProvider,
niDestination :: ServicePaymentDestination,
niExpiresAt :: UTCTime,
niCreatedAt :: UTCTime
}
data CreateError = CECodeConflict | CERefConflict | CEOther Text
deriving (Eq, Show)
newtype StoreDecodeError = StoreDecodeError Text
deriving (Eq, Show)
instance Exception StoreDecodeError
-- | SQLite stores timestamps as text, so `expires_at < ?` compares as strings and only sorts chronologically when every value has the same width.
truncateToSecond :: UTCTime -> UTCTime
truncateToSecond = posixSecondsToUTCTime . fromInteger . truncate . utcTimeToPOSIXSeconds
#if defined(dbPostgres)
mkQuery :: Text -> Query
mkQuery raw = fromString (T.unpack (withPrefix servicePrefix raw))
#else
mkQuery :: Text -> Query
mkQuery raw = withPrefix servicePrefix (fromString (T.unpack raw))
#endif
-- Backslash-newline string gaps do not survive CPP, so these queries are joined with <>.
qInsertInvoice :: Query
qInsertInvoice =
mkQuery $
"INSERT INTO @invoices "
<> "(invoice_id, provider, price, discount_amount, credit_amount, amount, currency, "
<> "payment_url, payment_address, payment_crypto_currency, payment_crypto_amount, "
<> "expires_at, status, created_at, updated_at) "
<> "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
qInsertBadgeCodeInvoice :: Query
qInsertBadgeCodeInvoice =
mkQuery $
"INSERT INTO @badge_code_invoices "
<> "(invoice_id, badge_code_id, price_id, offer_id, provider_ref, created_at) "
<> "VALUES (?,?,?,?,?,?)"
qInsertBadgeCode :: Query
qInsertBadgeCode =
mkQuery $
"INSERT INTO @badge_codes (code_hash, badge_type, months, code_payment_status, created_at) "
<> "VALUES (?,?,?,?,?)"
invoiceRowSelect :: Text
invoiceRowSelect =
"SELECT i.invoice_id, i.provider, ci.provider_ref, bc.badge_type, bc.months, "
<> "i.price, i.amount, i.currency, i.payment_url, i.payment_address, i.payment_crypto_currency, "
<> "i.payment_crypto_amount, i.expires_at, i.status, i.created_at, "
<> "p.amount, p.crypto_paid, p.crypto_due, p.paid_in_full, p.status, p.updated_at "
<> "FROM @invoices i "
<> "JOIN @badge_code_invoices ci ON ci.invoice_id = i.invoice_id "
<> "JOIN @badge_codes bc ON bc.badge_code_id = ci.badge_code_id "
<> "LEFT JOIN @payments p ON p.invoice_id = i.invoice_id "
qGetInvoice :: Query
qGetInvoice = mkQuery (invoiceRowSelect <> "WHERE i.invoice_id = ?")
qGetInvoiceByProviderRef :: Query
qGetInvoiceByProviderRef = mkQuery (invoiceRowSelect <> "WHERE ci.provider_ref = ?")
qUnpaidRefs :: Query
qUnpaidRefs =
mkQuery $
"SELECT i.provider, ci.provider_ref FROM @invoices i "
<> "JOIN @badge_code_invoices ci ON ci.invoice_id = i.invoice_id "
<> "WHERE i.status <> 'paid' AND i.created_at >= ? "
<> "ORDER BY i.created_at"
qCodeHashExists :: Query
qCodeHashExists = mkQuery "SELECT 1 FROM @badge_codes WHERE code_hash = ? LIMIT 1"
unfundedOnly :: Text
unfundedOnly =
"AND NOT EXISTS (SELECT 1 FROM @payments p "
<> "WHERE p.invoice_id = @invoices.invoice_id AND ("
<> "COALESCE(p.amount, 0) > 0 OR p.crypto_paid IS NOT NULL OR p.paid_in_full = 1)) "
qOverdueInvoices :: Query
qOverdueInvoices =
mkQuery $
"SELECT @invoices.invoice_id, @invoices.provider, ci.provider_ref, @invoices.created_at FROM @invoices "
<> "JOIN @badge_code_invoices ci ON ci.invoice_id = @invoices.invoice_id "
<> "WHERE status = 'open' AND expires_at < ? "
<> unfundedOnly
<> "ORDER BY @invoices.created_at"
qExpireOverdue :: Query
qExpireOverdue =
mkQuery $
"UPDATE @invoices SET status = 'expired', updated_at = ? "
<> "WHERE status = 'open' AND expires_at < ? AND invoice_id = ? "
<> unfundedOnly
<> "AND EXISTS (SELECT 1 FROM @badge_code_invoices ci WHERE ci.invoice_id = @invoices.invoice_id)"
qCodeHashForInvoice :: Query
qCodeHashForInvoice =
mkQuery $
"SELECT bc.code_hash FROM @badge_code_invoices ci "
<> "JOIN @badge_codes bc ON bc.badge_code_id = ci.badge_code_id "
<> "WHERE ci.invoice_id = ?"
-- | SQLite's two-argument MAX is GREATEST in Postgres, where MAX is an aggregate.
largerOf :: Text
#if defined(dbPostgres)
largerOf = "GREATEST"
#else
largerOf = "MAX"
#endif
-- Once a row is settled it stays settled, so a later pending update cannot overwrite it.
qUpsertPayment :: Query
qUpsertPayment =
mkQuery $
"INSERT INTO @payments "
<> "(payment_id, invoice_id, provider, provider_ref, amount, currency, crypto_paid, crypto_due, paid_in_full, status, created_at, updated_at) "
<> "VALUES (?,?,?,?,?,?,?,?,?,?,?,?) "
<> "ON CONFLICT (payment_id) DO UPDATE SET "
<> "amount = "
<> largerOf
<> "(COALESCE(@payments.amount, 0), excluded.amount), "
<> "crypto_paid = CASE WHEN excluded.amount > COALESCE(@payments.amount, 0) OR @payments.crypto_paid IS NULL "
<> "THEN excluded.crypto_paid ELSE @payments.crypto_paid END, "
<> "crypto_due = COALESCE(excluded.crypto_due, @payments.crypto_due), "
<> "paid_in_full = "
<> largerOf
<> "(@payments.paid_in_full, excluded.paid_in_full), "
<> "status = CASE WHEN "
<> alreadySettled
<> " THEN @payments.status ELSE excluded.status END, "
<> "updated_at = CASE WHEN "
<> alreadySettled
<> " THEN @payments.updated_at ELSE excluded.updated_at END"
where
alreadySettled = "@payments.status = '" <> paymentStatusText PSSettled <> "'"
qUpdateInvoiceStatus :: Query
qUpdateInvoiceStatus =
mkQuery "UPDATE @invoices SET status = ?, updated_at = ? WHERE invoice_id = ? AND status = ?"
qMarkCodePaid :: Query
qMarkCodePaid =
mkQuery $
"UPDATE @badge_codes SET code_payment_status = ?, expires_at = ? "
<> "WHERE code_hash = ? AND code_payment_status = ?"
qBadgePrices :: Query
qBadgePrices =
mkQuery $
"SELECT price_id, badge_type, month_price, currency, status, created_at "
<> "FROM @badge_prices WHERE status <> 'disabled'"
qBadgeOffers :: Query
qBadgeOffers =
mkQuery $
"SELECT offer_id, price_id, months, free_months, discount, status, created_at "
<> "FROM @badge_offers WHERE status <> 'disabled'"
qSeedBadgePrice :: Query
qSeedBadgePrice =
mkQuery $
"INSERT INTO @badge_prices (price_id, badge_type, month_price, currency, status, created_at) "
<> "VALUES (?,?,?,?,?,?) ON CONFLICT (price_id) DO NOTHING"
qSeedBadgeOffer :: Query
qSeedBadgeOffer =
mkQuery $
"INSERT INTO @badge_offers (offer_id, price_id, months, free_months, discount, status, created_at) "
<> "VALUES (?,?,?,?,?,?,?) ON CONFLICT (offer_id) DO NOTHING"
providerText :: PaymentProvider -> Text
providerText = \case
PPApple -> "apple"
PPGoogle -> "google"
PPStripe -> "stripe"
PPCrypto -> "crypto"
PPCode -> "code"
PPReceipt -> "receipt"
textToProvider :: Text -> Maybe PaymentProvider
textToProvider = \case
"apple" -> Just PPApple
"google" -> Just PPGoogle
"stripe" -> Just PPStripe
"crypto" -> Just PPCrypto
"code" -> Just PPCode
"receipt" -> Just PPReceipt
_ -> Nothing
cryptoCurrencyText :: CryptoCurrency -> Text
cryptoCurrencyText CCBtc = "btc"
cryptoCurrencyText CCXmr = "xmr"
textToCryptoCurrency :: Text -> Maybe CryptoCurrency
textToCryptoCurrency "btc" = Just CCBtc
textToCryptoCurrency "xmr" = Just CCXmr
textToCryptoCurrency _ = Nothing
invoiceStatusText :: InvoiceStatus -> Text
invoiceStatusText ISOpen = "open"
invoiceStatusText ISPaid = "paid"
invoiceStatusText ISExpired = "expired"
textToInvoiceStatus :: Text -> Maybe InvoiceStatus
textToInvoiceStatus "open" = Just ISOpen
textToInvoiceStatus "paid" = Just ISPaid
textToInvoiceStatus "expired" = Just ISExpired
textToInvoiceStatus _ = Nothing
itemStatusText :: BadgeItemStatus -> Text
itemStatusText BISActive = "active"
itemStatusText BISDeprecated = "deprecated"
itemStatusText BISDisabled = "disabled"
textToItemStatus :: Text -> Maybe BadgeItemStatus
textToItemStatus "active" = Just BISActive
textToItemStatus "deprecated" = Just BISDeprecated
textToItemStatus "disabled" = Just BISDisabled
textToItemStatus _ = Nothing
paymentStatusText :: PaymentStatus -> Text
paymentStatusText = \case
PSPending -> "pending"
PSSettled -> "settled"
PSFailed _ -> "failed"
decodeDiscount :: Maybe Word8 -> Maybe Word8 -> Maybe OfferDiscount
decodeDiscount (Just f) _ = Just (ODFreeMonths f)
decodeDiscount Nothing (Just d) = Just (ODDiscount d)
decodeDiscount Nothing Nothing = Nothing
discountCols :: OfferDiscount -> (Maybe Word32, Maybe Word32)
discountCols (ODFreeMonths f) = (Just (fromIntegral f), Nothing)
discountCols (ODDiscount d) = (Nothing, Just (fromIntegral d))
mkDestination :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe ServicePaymentDestination
mkDestination (Just url) _ _ _ = Just (SPDCard CPStripe url)
mkDestination Nothing (Just addr) (Just curTxt) (Just amt) = (\cur -> SPDCrypto cur addr amt) <$> textToCryptoCurrency curTxt
mkDestination _ _ _ _ = Nothing
destinationCols :: ServicePaymentDestination -> (Maybe Text, Maybe Text, Maybe Text, Maybe Text)
destinationCols (SPDCard _ url) = (Just url, Nothing, Nothing, Nothing)
destinationCols (SPDCrypto cur addr amt) = (Nothing, Just addr, Just (cryptoCurrencyText cur), Just amt)
note :: Text -> Maybe a -> Either Text a
note ctx = maybe (Left ctx) Right
type InvoiceCols =
(Text, Text, Text, BadgeType, Word32)
:. (Word32, Word32, Text, Maybe Text, Maybe Text, Maybe Text)
:. (Maybe Text, UTCTime, Text, UTCTime)
:. (Maybe Word32, Maybe Text, Maybe Text, Maybe Int, Maybe Text, Maybe UTCTime)
mkInvoiceRow :: InvoiceCols -> Either Text InvoiceRow
mkInvoiceRow
( (invId, providerTxt, providerRef, badgeType, months)
:. (price, amount, currency, url, addr, cryptoCur)
:. (cryptoAmt, expiresAt, statusTxt, createdAt)
:. (pAmount, pCryptoPaid, pCryptoDue, pPaidInFull, pStatus, pUpdatedAt)
) = do
provider <- note "invoices.provider" (textToProvider providerTxt)
status <- note "invoices.status" (textToInvoiceStatus statusTxt)
destination <- note "invoice payment destination" (mkDestination url addr cryptoCur cryptoAmt)
pure
InvoiceRow
{ irInvoiceId = InvoiceId invId,
irProvider = provider,
irProviderRef = providerRef,
irBadgeType = badgeType,
irMonths = fromIntegral months,
irPrice = CurrencyAmount price,
irAmount = CurrencyAmount amount,
irCurrency = currency,
irDestination = destination,
irExpiresAt = expiresAt,
irStatus = status,
irCreatedAt = createdAt,
irPayment = mkPayment pAmount pCryptoPaid pCryptoDue pPaidInFull pStatus pUpdatedAt
}
mkPayment :: Maybe Word32 -> Maybe Text -> Maybe Text -> Maybe Int -> Maybe Text -> Maybe UTCTime -> Maybe InvoicePayment
mkPayment amt cryptoPaid cryptoDue paidInFull status updatedAt =
InvoicePayment (CurrencyAmount <$> amt) cryptoPaid cryptoDue (paidInFull == Just 1) <$> status <*> updatedAt
selectInvoiceRow :: DB.Connection -> Query -> Text -> IO (Maybe InvoiceRow)
selectInvoiceRow db q param = do
rows <- DB.query db q (Only param)
case rows of
[] -> pure Nothing
(row : _) -> either (E.throwIO . StoreDecodeError) (pure . Just) (mkInvoiceRow row)
type BadgePriceCols = (Text, BadgeType, Word32, Text, Text, UTCTime)
mkBadgePrice :: BadgePriceCols -> Either Text BadgePrice
mkBadgePrice (priceId, badgeType, monthPrice, currency, statusTxt, createdAt) = do
status <- note "badge_prices.status" (textToItemStatus statusTxt)
pure
BadgePrice
{ priceId = BadgePriceId priceId,
badgeType,
monthPrice = CurrencyAmount monthPrice,
currency,
status,
createdAt
}
type BadgeOfferCols = (Text, Maybe Text, Word32, Maybe Word32, Maybe Word32, Text, UTCTime)
mkBadgeOffer :: BadgeOfferCols -> Either Text BadgeOffer
mkBadgeOffer (offerId, priceId, months, freeMonths, discountPct, statusTxt, createdAt) = do
status <- note "badge_offers.status" (textToItemStatus statusTxt)
discount <- note "badge_offers discount" (decodeDiscount (fromIntegral <$> freeMonths) (fromIntegral <$> discountPct))
pure
BadgeOffer
{ offerId = BadgeOfferId offerId,
priceId = BadgePriceId <$> priceId,
months = fromIntegral months,
discount,
status,
createdAt
}
createInvoiceRows :: DBStore -> NewInvoice -> IO (Either CreateError ())
createInvoiceRows st ni =
(Right <$> withTransaction st (`insertInvoiceRows` ni))
`E.catch` (pure . Left . classifyCreateError)
insertInvoiceRows :: DB.Connection -> NewInvoice -> IO ()
insertInvoiceRows db NewInvoice {..} = do
let InvoiceId invId = niInvoiceId
CurrencyAmount price = niPrice
CurrencyAmount amount = niAmount
discountAmount = price - amount
(url, addr, cryptoCur, cryptoAmt) = destinationCols niDestination
expiresAt = truncateToSecond niExpiresAt
createdAt = truncateToSecond niCreatedAt
BadgePriceId priceId = niPriceId
offerId = (\(BadgeOfferId o) -> o) <$> niOfferId
months = fromIntegral niMonths :: Word32
DB.execute
db
qInsertInvoice
( (invId, providerText niProvider, price, discountAmount, Nothing :: Maybe Word32, amount, niCurrency)
:. (url, addr, cryptoCur, cryptoAmt, expiresAt, invoiceStatusText ISOpen, createdAt, createdAt)
)
DB.execute
db
qInsertBadgeCode
(DB.Binary niCodeHash, niBadgeType, months, textEncode CPSUnpaid, createdAt)
badgeCodeId <- insertedRowId db
DB.execute
db
qInsertBadgeCodeInvoice
(invId, badgeCodeId, priceId, offerId, niProviderRef, createdAt)
#if defined(dbPostgres)
classifyCreateError :: DB.SQLError -> CreateError
classifyCreateError e = case constraintViolation e of
Just (UniqueViolation name)
| "code_hash" `T.isInfixOf` nameText -> CECodeConflict
| "provider_ref" `T.isInfixOf` nameText -> CERefConflict
| otherwise -> CEOther (tshow e)
where
nameText = safeDecodeUtf8 name
_ -> CEOther (tshow e)
#else
classifyCreateError :: DB.SQLError -> CreateError
classifyCreateError e
| SQL.sqlError e == SQL.ErrorConstraint =
if "code_hash" `T.isInfixOf` details
then CECodeConflict
else
if "provider_ref" `T.isInfixOf` details
then CERefConflict
else CEOther details
| otherwise = CEOther (tshow e)
where
details = SQL.sqlErrorDetails e
#endif
getInvoice :: DBStore -> InvoiceId -> IO (Maybe InvoiceRow)
getInvoice st (InvoiceId invId) = withConnection st $ \db -> selectInvoiceRow db qGetInvoice invId
getInvoiceByProviderRef :: DBStore -> Text -> IO (Maybe InvoiceRow)
getInvoiceByProviderRef st ref = withConnection st $ \db -> selectInvoiceRow db qGetInvoiceByProviderRef ref
unpaidRefs :: DBStore -> UTCTime -> IO [(Text, Text)]
unpaidRefs st since = withConnection st $ \db -> DB.query db qUnpaidRefs (Only since)
codeHashExists :: DBStore -> ByteString -> IO Bool
codeHashExists st codeHash = withConnection st $ \db ->
not . null <$> (DB.query db qCodeHashExists (Only (DB.Binary codeHash)) :: IO [Only Int])
data OverdueInvoice = OverdueInvoice
{ oiInvoiceId :: InvoiceId,
oiProvider :: PaymentProvider,
oiProviderRef :: Text,
oiCreatedAt :: UTCTime
}
deriving (Eq, Show)
overdueInvoices :: DBStore -> UTCTime -> IO [OverdueInvoice]
overdueInvoices st cutoff = withConnection st $ \db -> do
rows <- DB.query db qOverdueInvoices (Only (truncateToSecond cutoff))
either (E.throwIO . StoreDecodeError) pure (traverse toOverdue rows)
where
toOverdue (i, providerTxt, oiProviderRef, oiCreatedAt) = case textToProvider providerTxt of
Just oiProvider -> Right OverdueInvoice {oiInvoiceId = InvoiceId i, oiProvider, oiProviderRef, oiCreatedAt}
Nothing -> Left ("invoices.provider: " <> providerTxt)
-- | Of the given invoices, expire those that are still open, have received no money, and are past their deadline.
expireOverdue :: DBStore -> UTCTime -> [InvoiceId] -> IO [InvoiceId]
expireOverdue st cutoff' invIds = withTransaction st $ \db -> do
let cutoff = truncateToSecond cutoff'
filterM (\(InvoiceId i) -> (> 0) <$> executeChanging db qExpireOverdue (cutoff, cutoff, i)) invIds
cancelOpenInvoice :: DBStore -> InvoiceId -> UTCTime -> IO Bool
cancelOpenInvoice st invId at = withTransaction st $ \db -> updateInvoiceStatus db invId ISOpen ISExpired at
readCatalogRows :: DBStore -> IO ([BadgePrice], [BadgeOffer])
readCatalogRows st = withConnection st $ \db -> do
priceRows <- DB.query_ db qBadgePrices
offerRows <- DB.query_ db qBadgeOffers
prices <- either (E.throwIO . StoreDecodeError) pure (traverse mkBadgePrice priceRows)
offers <- either (E.throwIO . StoreDecodeError) pure (traverse mkBadgeOffer offerRows)
pure (prices, offers)
seedCatalog :: DBStore -> [BadgePrice] -> [BadgeOffer] -> IO (Int, Int)
seedCatalog st prices offers = withTransaction st $ \db -> do
seededPrices <- sum <$> mapM (seedPrice db) prices
seededOffers <- sum <$> mapM (seedOffer db) offers
pure (seededPrices, seededOffers)
where
seedPrice db BadgePrice {priceId = BadgePriceId pId, badgeType = bType, monthPrice = CurrencyAmount mPrice, currency = cur, status = pStatus, createdAt = at} =
executeChanging db qSeedBadgePrice (pId, bType, mPrice, cur, itemStatusText pStatus, truncateToSecond at)
seedOffer db BadgeOffer {offerId = BadgeOfferId oId, priceId = oPriceId, months = mMonths, discount = disc, status = oStatus, createdAt = at} =
let (freeMonths, discountPct) = discountCols disc
in executeChanging
db
qSeedBadgeOffer
( oId,
(\(BadgePriceId p) -> p) <$> oPriceId,
fromIntegral mMonths :: Word32,
freeMonths,
discountPct,
itemStatusText oStatus,
truncateToSecond at
)
executeChanging :: ToRow q => DB.Connection -> Query -> q -> IO Int
#if defined(dbPostgres)
executeChanging db q params = fromIntegral <$> PSQL.execute db q params
#else
executeChanging db q params = DB.execute db q params >> SQL.changes (DB.conn db)
#endif
settlementInvoice :: DB.Connection -> InvoiceId -> IO (Maybe InvoiceRow)
settlementInvoice db (InvoiceId invId) = selectInvoiceRow db qGetInvoice invId
settlementCodeHash :: DB.Connection -> InvoiceId -> IO (Maybe ByteString)
settlementCodeHash db (InvoiceId invId) = do
rows <- DB.query db qCodeHashForInvoice (Only invId) :: IO [Only (Maybe (DB.Binary ByteString))]
pure $ case rows of
(Only codeHash : _) -> DB.fromBinary <$> codeHash
[] -> Nothing
upsertPayment :: DB.Connection -> InvoiceRow -> PaymentStatus -> CurrencyAmount -> Maybe Text -> Maybe Text -> Bool -> UTCTime -> IO ()
upsertPayment db InvoiceRow {irInvoiceId, irProvider, irProviderRef, irCurrency} status (CurrencyAmount amount) cryptoAmount cryptoDue paidInFull at' =
DB.execute
db
qUpsertPayment
( (invId, invId, providerText irProvider, irProviderRef, amount)
:. (irCurrency, cryptoAmount, cryptoDue, if paidInFull then 1 :: Int else 0, paymentStatusText status, at, at)
)
where
InvoiceId invId = irInvoiceId
at = truncateToSecond at'
updateInvoiceStatus :: DB.Connection -> InvoiceId -> InvoiceStatus -> InvoiceStatus -> UTCTime -> IO Bool
updateInvoiceStatus db (InvoiceId invId) observed new at =
(> 0) <$> executeChanging db qUpdateInvoiceStatus (invoiceStatusText new, truncateToSecond at, invId, invoiceStatusText observed)
markCodePaid :: DB.Connection -> ByteString -> UTCTime -> IO ()
markCodePaid db codeHash expiresAt =
DB.execute
db
qMarkCodePaid
(textEncode CPSPaid, truncateToSecond expiresAt, DB.Binary codeHash, textEncode CPSUnpaid)
newInvoiceId :: IO InvoiceId
newInvoiceId = InvoiceId . safeDecodeUtf8 . BC8.filter (/= '=') . B64U.encode <$> getRandomBytes 16
@@ -2,7 +2,7 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations) where
module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations, servicePrefix, withPrefix) where
import Data.List (sortOn)
import Data.Text (Text)
@@ -17,19 +17,29 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations
schemaMigrations :: [(String, Text, Maybe Text)]
schemaMigrations =
[ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_badge_service_schema)
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema)
]
-- the client tables are in the same database, so the service tables are the same names with this prefix
-- | The client tables share this database, so the service tables are the same names behind a prefix.
servicePrefix :: Text
servicePrefix = "sx_badge_service_"
m20260806_badge_service_schema :: Text
m20260806_badge_service_schema =
m20260915_badge_service_schema :: Text
m20260915_badge_service_schema =
badgeSchema servicePrefix
<> withPrefix
servicePrefix
-- The payment columns are added to @payments, which badgeSchema owns. crypto_paid,
-- crypto_due and paid_in_full record the provider's own figures: it applies a payment
-- tolerance and adds a network fee after a partial payment, so what is owed and whether
-- an invoice is settled are its verdicts, not amounts recomputable from what we store.
[r|
ALTER TABLE @payments ADD COLUMN crypto_paid TEXT;
ALTER TABLE @payments ADD COLUMN crypto_due TEXT;
ALTER TABLE @payments ADD COLUMN paid_in_full SMALLINT NOT NULL DEFAULT 0;
CREATE TABLE @badge_codes(
badge_code_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code_hash BYTEA NOT NULL,
@@ -37,6 +47,8 @@ CREATE TABLE @badge_codes(
months SMALLINT NOT NULL,
code_payment_status TEXT NOT NULL,
redeemed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE(code_hash)
);
@@ -47,22 +59,43 @@ CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id)
CREATE TABLE @badge_code_invoices(
invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE,
badge_code_id BIGINT NOT NULL REFERENCES @badge_codes,
price_id TEXT NOT NULL REFERENCES @badge_prices,
offer_id TEXT REFERENCES @badge_offers,
months SMALLINT NOT NULL,
provider_ref TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id);
CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id);
-- provider_ref is the provider's own id for the invoice; unique so a webhook or poller read
-- resolves a payment to exactly one invoice.
CREATE UNIQUE INDEX @idx_badge_code_invoices_provider_ref ON @badge_code_invoices(provider_ref);
|]
-- Two filters run on every poller pass and neither may read the whole table, or the pass
-- lengthens for as long as the service keeps selling: the expiry sweep, on (status, expires_at),
-- and the read lane, which takes a window of created_at. Status leads the first because it is
-- matched by equality there; the second matches it with <>, which no index can seek, so it seeks
-- the window and filters what little that leaves.
<> withPrefix
servicePrefix
[r|
CREATE INDEX @idx_invoices_status_expires_at ON @invoices(status, expires_at);
CREATE INDEX @idx_invoices_created ON @invoices(created_at);
|]
down_m20260806_badge_service_schema :: Text
down_m20260806_badge_service_schema =
down_m20260915_badge_service_schema :: Text
down_m20260915_badge_service_schema =
withPrefix
servicePrefix
[r|
DROP INDEX @idx_invoices_created;
DROP INDEX @idx_invoices_status_expires_at;
DROP INDEX @idx_badge_code_invoices_provider_ref;
DROP INDEX @idx_badge_code_invoices_offer;
DROP INDEX @idx_badge_code_invoices_price;
DROP TABLE @badge_code_invoices;
@@ -2,7 +2,7 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations) where
module BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations, servicePrefix, withPrefix) where
import Data.List (sortOn)
import Data.Text (Text)
@@ -18,19 +18,29 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations
schemaMigrations :: [(String, Query, Maybe Query)]
schemaMigrations =
[ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_badge_service_schema)
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema)
]
-- the client tables are in the same database, so the service tables are the same names with this prefix
-- | The client tables share this database, so the service tables are the same names behind a prefix.
servicePrefix :: Text
servicePrefix = "sx_badge_service_"
m20260806_badge_service_schema :: Query
m20260806_badge_service_schema =
m20260915_badge_service_schema :: Query
m20260915_badge_service_schema =
badgeSchema servicePrefix
<> withPrefix
servicePrefix
-- The payment columns are added to @payments, which badgeSchema owns. crypto_paid,
-- crypto_due and paid_in_full record the provider's own figures: it applies a payment
-- tolerance and adds a network fee after a partial payment, so what is owed and whether
-- an invoice is settled are its verdicts, not amounts recomputable from what we store.
[sql|
ALTER TABLE @payments ADD COLUMN crypto_paid TEXT;
ALTER TABLE @payments ADD COLUMN crypto_due TEXT;
ALTER TABLE @payments ADD COLUMN paid_in_full INTEGER NOT NULL DEFAULT 0;
CREATE TABLE @badge_codes(
badge_code_id INTEGER PRIMARY KEY AUTOINCREMENT,
code_hash BLOB NOT NULL,
@@ -38,6 +48,8 @@ CREATE TABLE @badge_codes(
months INTEGER NOT NULL,
code_payment_status TEXT NOT NULL,
redeemed_at TEXT,
expires_at TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL,
UNIQUE(code_hash)
) STRICT;
@@ -48,22 +60,43 @@ CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id)
CREATE TABLE @badge_code_invoices(
invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE,
badge_code_id INTEGER NOT NULL REFERENCES @badge_codes,
price_id TEXT NOT NULL REFERENCES @badge_prices,
offer_id TEXT REFERENCES @badge_offers,
months INTEGER NOT NULL,
provider_ref TEXT NOT NULL,
created_at TEXT NOT NULL
) STRICT;
CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id);
CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id);
-- provider_ref is the provider's own id for the invoice; unique so a webhook or poller read
-- resolves a payment to exactly one invoice.
CREATE UNIQUE INDEX @idx_badge_code_invoices_provider_ref ON @badge_code_invoices(provider_ref);
|]
-- Two filters run on every poller pass and neither may read the whole table, or the pass
-- lengthens for as long as the service keeps selling: the expiry sweep, on (status, expires_at),
-- and the read lane, which takes a window of created_at. Status leads the first because it is
-- matched by equality there; the second matches it with <>, which no index can seek, so it seeks
-- the window and filters what little that leaves.
<> withPrefix
servicePrefix
[sql|
CREATE INDEX @idx_invoices_status_expires_at ON @invoices(status, expires_at);
CREATE INDEX @idx_invoices_created ON @invoices(created_at);
|]
down_m20260806_badge_service_schema :: Query
down_m20260806_badge_service_schema =
down_m20260915_badge_service_schema :: Query
down_m20260915_badge_service_schema =
withPrefix
servicePrefix
[sql|
DROP INDEX @idx_invoices_created;
DROP INDEX @idx_invoices_status_expires_at;
DROP INDEX @idx_badge_code_invoices_provider_ref;
DROP INDEX @idx_badge_code_invoices_offer;
DROP INDEX @idx_badge_code_invoices_price;
DROP TABLE @badge_code_invoices;
@@ -0,0 +1,91 @@
{-# LANGUAGE NamedFieldPuns #-}
module BadgeService.Waiters (Waiters, Seen, newWaiters, publish, publishPayment, awaitStatus, waitingCount, waitingCountSTM) where
import Control.Concurrent.STM
import Control.Exception (bracket)
import Control.Monad (forM_, when)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, isNothing)
import Data.Text (Text)
import Simplex.Chat.PaymentService.Types (InvoiceId (..), InvoiceStatus)
-- | wStatus stays Nothing until a publish or a read fills it, so a publish mid-read is not overwritten by the older answer.
data Watch = Watch {wStatus :: TVar (Maybe InvoiceStatus), wPayments :: TVar Int, wRefs :: TVar Int}
newtype Waiters = Waiters (TVar (Map Text Watch))
type Seen = (InvoiceStatus, (Text, Bool))
newWaiters :: IO Waiters
newWaiters = Waiters <$> newTVarIO Map.empty
-- | Call after the settling transaction commits. The reader re-reads the row, so an overwriting publish costs nothing.
publish :: Waiters -> InvoiceId -> InvoiceStatus -> STM ()
publish (Waiters wv) (InvoiceId iid) status = do
watches <- readTVar wv
forM_ (Map.lookup iid watches) $ \Watch {wStatus} -> writeTVar wStatus (Just status)
-- | Call after the commit, for a payment that did not move the status.
publishPayment :: Waiters -> InvoiceId -> STM ()
publishPayment (Waiters wv) (InvoiceId iid) = do
watches <- readTVar wv
forM_ (Map.lookup iid watches) $ \Watch {wPayments} -> modifyTVar' wPayments (+ 1)
subscribe :: Waiters -> InvoiceId -> STM Watch
subscribe (Waiters wv) (InvoiceId iid) = do
watches <- readTVar wv
case Map.lookup iid watches of
Just watch@Watch {wRefs} -> do
modifyTVar' wRefs (+ 1)
pure watch
Nothing -> do
status <- newTVar Nothing
payments <- newTVar 0
refs <- newTVar 1
let watch = Watch {wStatus = status, wPayments = payments, wRefs = refs}
writeTVar wv (Map.insert iid watch watches)
pure watch
release :: Waiters -> InvoiceId -> STM ()
release (Waiters wv) (InvoiceId iid) = do
watches <- readTVar wv
forM_ (Map.lookup iid watches) $ \Watch {wRefs} -> do
n <- pred <$> readTVar wRefs
if n <= 0
then writeTVar wv (Map.delete iid watches)
else writeTVar wRefs n
-- | Subscribe, then read, then block; reading first would miss a settlement landing in between.
awaitStatus :: Waiters -> InvoiceId -> IO Seen -> Seen -> Int -> IO InvoiceStatus
awaitStatus w iid readSeen seen@(seenStatus, _) usec =
bracket (atomically $ subscribe w iid) (const . atomically $ release w iid) $ \Watch {wStatus, wPayments} -> do
paidAt <- readTVarIO wPayments
current@(currentStatus, _) <- readSeen -- after subscribing, never before
-- a publish that landed between subscribe and this read is the fresher answer, so keep it
atomically $ readTVar wStatus >>= \published -> when (isNothing published) (writeTVar wStatus (Just currentStatus))
if current /= seen
then pure currentStatus
else do
timer <- registerDelay usec
atomically $
( do
published <- readTVar wStatus
payments <- readTVar wPayments
case published of
-- seeded above and only republished, so this stays `Just` while the bracket holds its reference
Just s | s /= seenStatus || payments /= paidAt -> pure s
_ -> retry
)
`orElse` ( do
readTVar timer >>= check
fromMaybe seenStatus <$> readTVar wStatus
)
waitingCount :: Waiters -> IO Int
waitingCount = atomically . waitingCountSTM
-- | In STM so the poller can block on it changing, letting an arriving browser shorten the sleep it lands in.
waitingCountSTM :: Waiters -> STM Int
waitingCountSTM (Waiters wv) = Map.size <$> readTVar wv
@@ -0,0 +1,741 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module BadgeService.Web.Server
( WebEnv (..),
Limit (..),
newWebEnv,
exportWebapp,
takeToken,
maxBuckets,
maxWebhookBytes,
webApp,
webSettings,
runWebListener,
holdMicros,
readLimit,
)
where
import BadgeService.Catalog (PricedOffer (..), priceOffer)
import BadgeService.Config (BTCPayConfig (..), ListenerConfig (..), ServiceConfig (..), SpeedPolicy (..), StripeConfig (..), defaultExpiryMinutes, defaultSessionMinutes)
import BadgeService.Poller (ReadHints, queueReadHint)
import BadgeService.Providers (OrderDraft (..), Provider (..), ProviderError (..), ProviderInvoice (..), WebhookError (..))
import BadgeService.Store.Invoices (CreateError (..), InvoicePayment (..), InvoiceRow (..), NewInvoice (..), cancelOpenInvoice, codeHashExists, createInvoiceRows, cryptoCurrencyText, getInvoice, getInvoiceByProviderRef, invoiceStatusText, newInvoiceId, paymentHolds, readCatalogRows, textToInvoiceStatus, truncateToSecond)
import BadgeService.Waiters (Seen, Waiters, awaitStatus, publish)
import Control.Concurrent.STM
import qualified Control.Exception as E
import BadgeService.Log (logError, logInfo, logWarn)
import Control.Monad (forM_, when)
import Data.Aeson ((.=))
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import Data.Aeson.Types (Pair)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64U
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as LB
import Data.Char (toLower)
import Data.List (find, isPrefixOf, sortOn)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8', encodeUtf8)
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import Data.Word (Word16)
import Network.HTTP.Types (Header, Status, hCacheControl, hContentType, status200, status400, status404, status405, status409, status413, status429, status500, status503)
import Network.Socket (SockAddr (..), hostAddress6ToTuple, hostAddressToTuple)
import Network.Wai
import qualified Network.Wai.Handler.Warp as Warp
import Numeric (showHex)
import Simplex.Chat.Badges.Types (BadgeOfferId (..), BadgePriceId (..))
import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), ServicePaymentDestination (..), ServicePaymentMethod (..))
import Simplex.Messaging.Agent.Store.Common (DBStore)
import Simplex.Messaging.Encoding.String (textEncode)
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
import System.Directory (canonicalizePath, copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, listDirectory, removePathForcibly)
import System.FilePath (pathSeparator, takeExtension, (</>))
import Text.Read (readMaybe)
data WebEnv = WebEnv
{ weStore :: DBStore,
weConfig :: ServiceConfig,
weWaiters :: Waiters,
weProviders :: [Provider],
weHoldMicros :: Int,
weHints :: ReadHints,
weBuckets :: TVar (Map Text Bucket),
-- | @Nothing@ when no Stripe key is configured, and the pristine @static_dir@ shell is served instead.
weShell :: Maybe LB.ByteString
}
newWebEnv :: DBStore -> ServiceConfig -> Waiters -> ReadHints -> [Provider] -> IO WebEnv
newWebEnv weStore weConfig weWaiters weHints weProviders = do
weBuckets <- newTVarIO Map.empty
weShell <- prepareShell (listener weConfig) (stripe weConfig)
pure WebEnv {weStore, weConfig, weWaiters, weProviders, weHoldMicros = holdMicros, weHints, weBuckets, weShell}
listenerConfig :: WebEnv -> ListenerConfig
listenerConfig WebEnv {weConfig} = listener weConfig
type Respond = Response -> IO ResponseReceived
-- | Short enough to stay under the idle timeout of any proxy in front of us.
holdMicros :: Int
holdMicros = 30 * 1000000
readLimit :: Limit
readLimit = Limit {lmName = "read", lmPerMinute = 60}
createLimit :: Limit
createLimit = Limit {lmName = "create", lmPerMinute = 5}
maxBodyBytes :: Int
maxBodyBytes = 8192
maxWebhookBytes :: Int
maxWebhookBytes = 64 * 1024
sha256Bytes :: Int
sha256Bytes = 32
codeHashChars :: Int
codeHashChars = 43
data Limit = Limit {lmName :: Text, lmPerMinute :: Int}
deriving (Eq, Show)
data Bucket = Bucket {bkCount :: Int, bkStarted :: UTCTime}
limitWindow :: NominalDiffTime
limitWindow = 60
maxBuckets :: Int
maxBuckets = 8192
reclaim :: UTCTime -> Map Text Bucket -> Map Text Bucket
reclaim now buckets
| Map.size buckets < maxBuckets = buckets
| Map.size live * 2 <= maxBuckets = live
| otherwise = Map.fromList (drop (Map.size live `div` 2) (sortOn (bkCount . snd) (Map.toList live)))
where
live = Map.filter (fresh now) buckets
fresh :: UTCTime -> Bucket -> Bool
fresh now Bucket {bkStarted} = diffUTCTime now bkStarted < limitWindow
takeToken :: WebEnv -> Limit -> Text -> IO (Maybe Int)
takeToken WebEnv {weBuckets} Limit {lmName, lmPerMinute} client = do
now <- getCurrentTime
atomically $ do
buckets <- reclaim now <$> readTVar weBuckets
let key = lmName <> "\t" <> client
count bucket = writeTVar weBuckets (Map.insert key bucket buckets) >> pure Nothing
-- write the reclaimed map back here too, or a refused request redoes the filtering every time and the map stays at the limit
refuse seconds = writeTVar weBuckets buckets >> pure (Just seconds)
case Map.lookup key buckets of
Just bucket@Bucket {bkCount, bkStarted}
| fresh now bucket ->
if bkCount < lmPerMinute
then count bucket {bkCount = bkCount + 1}
else refuse (secondsLeft now bkStarted)
_ -> count Bucket {bkCount = 1, bkStarted = now}
where
secondsLeft :: UTCTime -> UTCTime -> Int
secondsLeft now started = max 1 (ceiling (limitWindow - diffUTCTime now started))
limited :: WebEnv -> Limit -> Request -> Respond -> IO ResponseReceived -> IO ResponseReceived
limited env limit req respond action =
takeToken env limit (clientKey env req) >>= \case
Nothing -> action
Just seconds -> respond (rateLimited seconds)
-- | Every line is joined first, so a second header line a caller adds cannot leave its own line the one we read.
clientKey :: WebEnv -> Request -> Text
clientKey env req = fromMaybe (peerText (remoteHost req)) forwarded
where
forwarded
| not (lTrustForwardedFor (listenerConfig env)) = Nothing
| otherwise = case reverse (concatMap entries (requestHeaders req)) of
(ip : _) | isIpAddress ip -> Just ip
_ -> Nothing
entries (name, raw)
| name /= "x-forwarded-for" = []
| otherwise = either (const []) (filter (not . T.null) . map T.strip . T.splitOn ",") (decodeUtf8' raw)
isIpAddress :: Text -> Bool
isIpAddress t = isIPv4 t || isIPv6 t
asciiDigit :: Char -> Bool
asciiDigit c = c >= '0' && c <= '9'
-- Data.Char.isDigit and isHexDigit accept Unicode digits, which are not addresses.
asciiHexDigit :: Char -> Bool
asciiHexDigit c = asciiDigit c || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
-- | A leading zero is refused, so "010.0.0.1" cannot be a second key for "10.0.0.1".
isIPv4 :: Text -> Bool
isIPv4 t = case T.splitOn "." t of
[a, b, c, d] -> all octet [a, b, c, d]
_ -> False
where
octet s =
not (T.null s)
&& T.length s <= 3
&& T.all asciiDigit s
&& (T.length s == 1 || T.head s /= '0')
&& maybe False (<= 255) (readMaybe (T.unpack s) :: Maybe Int)
isIPv6 :: Text -> Bool
isIPv6 t
| T.any (== '%') t = False
| otherwise = case T.splitOn "::" t of
[full] -> groupCount True (segments full) == Just 8
[before, after] -> case (groupCount False (segments before), groupCount True (segments after)) of
(Just b, Just a) -> b + a <= 7 -- "::" stands for at least one omitted group
_ -> False
_ -> False
where
segments s = if T.null s then [] else T.splitOn ":" s
groupCount :: Bool -> [Text] -> Maybe Int
groupCount endsAddress gs
| null gs = Just 0
| endsAddress && isIPv4 (last gs) = (+ 2) <$> plainGroups (init gs)
| otherwise = plainGroups gs
where
plainGroups ps = if all hexGroup ps then Just (length ps) else Nothing
hexGroup g = not (T.null g) && T.length g <= 4 && T.all asciiHexDigit g
peerText :: SockAddr -> Text
peerText = \case
SockAddrInet _ addr ->
let (a, b, c, d) = hostAddressToTuple addr
in T.intercalate "." (map tshowInt [a, b, c, d])
SockAddrInet6 _ _ addr _ ->
let (a, b, c, d, e, f, g, h) = hostAddress6ToTuple addr
in T.intercalate ":" (map hex16 [a, b, c, d, e, f, g, h])
SockAddrUnix p -> "unix:" <> T.pack p
where
tshowInt :: Show a => a -> Text
tshowInt = T.pack . show
hex16 :: Word16 -> Text
hex16 w = T.pack (showHex w "")
apiHeaders :: [Header]
apiHeaders = [(hContentType, "application/json"), (hCacheControl, "no-store")]
jsonResponse :: Status -> J.Value -> Response
jsonResponse st = responseLBS st apiHeaders . J.encode
jsonError :: Status -> Text -> Response
jsonError st code = jsonResponse st (J.object ["error" .= code])
notFound :: Response
notFound = jsonError status404 "not_found"
badRequest :: Response
badRequest = jsonError status400 "bad_request"
catalogChanged :: Response
catalogChanged = jsonError status400 "catalog_changed"
codeConflict :: Response
codeConflict = jsonError status409 "code_conflict"
providerUnavailable :: Response
providerUnavailable = jsonError status503 "provider_unavailable"
rateLimited :: Int -> Response
rateLimited seconds =
responseLBS
status429
(("Retry-After", B.pack (show seconds)) : apiHeaders)
(J.encode (J.object ["error" .= ("rate_limited" :: Text)]))
methodNotAllowed :: ByteString -> Response
methodNotAllowed allow = responseLBS status405 (("Allow", allow) : apiHeaders) (J.encode (J.object ["error" .= ("method_not_allowed" :: Text)]))
internalError :: Response
internalError = jsonError status500 "internal"
webhookOk, webhookRefused, webhookTooLarge :: Response
webhookOk = webhookResponse status200
webhookRefused = webhookResponse status400
webhookTooLarge = webhookResponse status413
webhookResponse :: Status -> Response
webhookResponse st = responseLBS st [(hCacheControl, "no-store")] ""
webApp :: WebEnv -> Application
webApp env req respond = case pathInfo req of
[] | serving -> only "GET" $ case weShell env of
Just shell -> respond (responseLBS status200 shellHeaders shell)
Nothing -> serveStatic env ["index.html"] respond
"assets" : rest | serving -> only "GET" $ serveStatic env ("assets" : rest) respond
["sw.js"] | serving -> only "GET" $ serveStatic env ["sw.js"] respond
["api", "invoice"] -> only "POST" $ limited env createLimit req respond $ createInvoiceHandler env req respond
["api", "invoice", iid] -> only "GET" $ limited env readLimit req respond $ readInvoiceHandler env (InvoiceId iid) req respond
["api", "invoice", iid, "cancel"] -> only "POST" $ limited env createLimit req respond $ cancelInvoiceHandler env (InvoiceId iid) respond
["webhooks", "btcpay"] -> only "POST" $ webhookHandler env PPCrypto "POST /webhooks/btcpay" req respond
["webhooks", "stripe"] -> only "POST" $ webhookHandler env PPStripe "POST /webhooks/stripe" req respond
_ -> respond notFound
where
serving = lServeWebapp (listenerConfig env)
only method action
| requestMethod req == method = action
| otherwise = respond (methodNotAllowed method)
-- | A @".."@ check would miss separators WAI has already decoded, an absolute path, and a symlink out of the tree.
serveStatic :: WebEnv -> [Text] -> Respond -> IO ResponseReceived
serveStatic env segments respond =
resolveInside (lStaticDir (listenerConfig env)) segments >>= \case
Nothing -> respond notFound
Just (root, file) -> respond (responseFile status200 (staticHeaders root file) file Nothing)
shellHeaders :: [Header]
shellHeaders = [(hContentType, "text/html; charset=utf-8"), (hCacheControl, "no-cache")]
publishableKeyMeta :: Text
publishableKeyMeta = "id=\"stripe-publishable-key\" name=\"stripe-publishable-key\" content=\""
-- | @Nothing@ (serve the pristine file) when no key is set, no shell is present, or it cannot be read.
prepareShell :: ListenerConfig -> Maybe StripeConfig -> IO (Maybe LB.ByteString)
prepareShell _ Nothing = pure Nothing
prepareShell ListenerConfig {lStaticDir} (Just StripeConfig {sPublishableKey}) =
E.try attempt >>= \case
Right out -> pure out
Left (e :: E.IOException) -> do
logWarn ("could not read the shell in " <> T.pack lStaticDir <> ", serving the pristine one: " <> tshow e)
pure Nothing
where
attempt :: IO (Maybe LB.ByteString)
attempt = do
let shell = lStaticDir </> "index.html"
present <- doesFileExist shell
if not present
then pure Nothing
else do
-- TIO.readFile would decode in the locale's encoding, which fails outright under a C locale.
bytes <- BS.readFile shell
case decodeUtf8' bytes of
Left e -> do
logWarn ("the shell in " <> T.pack lStaticDir <> " is not valid UTF-8, serving the pristine one: " <> tshow e)
pure Nothing
Right html -> pure (Just (LB.fromStrict (encodeUtf8 (injectPublishableKey sPublishableKey html))))
exportWebapp :: ListenerConfig -> Maybe StripeConfig -> IO ()
exportWebapp lc@ListenerConfig {lStaticDir, lWebappExportDir} stripeCfg =
forM_ lWebappExportDir $ \out -> do
emptyDir out
copyTree lStaticDir out
prepareShell lc stripeCfg >>= mapM_ (LB.writeFile (out </> "index.html"))
logInfo ("badge service: exported the webapp to " <> T.pack out)
-- | Contents only: the directory itself may be a bind or volume mount point that cannot be removed.
emptyDir :: FilePath -> IO ()
emptyDir dir = do
createDirectoryIfMissing True dir
entries <- listDirectory dir
forM_ entries (removePathForcibly . (dir </>))
copyTree :: FilePath -> FilePath -> IO ()
copyTree src dst = do
isDir <- doesDirectoryExist src
if isDir
then do
createDirectoryIfMissing True dst
listDirectory src >>= mapM_ (\e -> copyTree (src </> e) (dst </> e))
else copyFile src dst
injectPublishableKey :: Text -> Text -> Text
injectPublishableKey key = T.replace (publishableKeyMeta <> "\"") (publishableKeyMeta <> key <> "\"")
resolveInside :: FilePath -> [Text] -> IO (Maybe (FilePath, FilePath))
resolveInside dir segments = either ioFailed id <$> E.try attempt
where
-- a NUL byte in the path makes the calls below throw
ioFailed :: E.IOException -> Maybe (FilePath, FilePath)
ioFailed _ = Nothing
attempt :: IO (Maybe (FilePath, FilePath))
attempt = do
root <- canonicalizePath dir
file <- canonicalizePath (foldl (\acc s -> acc </> T.unpack s) root segments)
exists <- doesFileExist file
pure $ if exists && inside root file then Just (root, file) else Nothing
inside :: FilePath -> FilePath -> Bool
inside root file = (root <> [pathSeparator]) `isPrefixOf` file
-- | The page at @\/@ must not be cached, or a browser would keep asking for assets from a build we no longer have.
staticHeaders :: FilePath -> FilePath -> [Header]
staticHeaders root file =
[ (hContentType, contentTypeFor file),
(hCacheControl, if hashedAsset then "public, max-age=31536000, immutable" else "no-cache")
]
where
-- read off the resolved path, not the request: `/assets/<hash>/%2e%2e/%2e%2e/index.html` resolves to the shell
hashedAsset = (root </> "assets" <> [pathSeparator]) `isPrefixOf` file
contentTypeFor :: FilePath -> ByteString
contentTypeFor file = case map toLower (takeExtension file) of
".html" -> "text/html; charset=utf-8"
".css" -> "text/css; charset=utf-8"
".js" -> "text/javascript; charset=utf-8"
".json" -> "application/json"
".webmanifest" -> "application/manifest+json"
".svg" -> "image/svg+xml"
".png" -> "image/png"
".ico" -> "image/vnd.microsoft.icon"
".woff2" -> "font/woff2"
".txt" -> "text/plain; charset=utf-8"
_ -> "application/octet-stream"
readInvoiceHandler :: WebEnv -> InvoiceId -> Request -> Respond -> IO ResponseReceived
readInvoiceHandler env@WebEnv {weStore} invId req respond =
getInvoice weStore invId >>= \case
Nothing -> respond notFound
Just row -> case holdFor row of
Nothing -> respond (jsonResponse status200 (invoiceView (confirmationsFor env) row))
Just seen -> do
-- Settlement publishes after it commits, so the row read below is at least as new as whatever woke us.
_ <- awaitStatus (weWaiters env) invId readSeen seen (weHoldMicros env)
getInvoice weStore invId >>= \case
Nothing -> respond notFound
Just row' -> respond (jsonResponse status200 (invoiceView (confirmationsFor env) row'))
where
holdFor :: InvoiceRow -> Maybe Seen
holdFor row@InvoiceRow {irStatus} = case waitParam req of
Just seen | seen == irStatus, seen /= ISPaid, samePayment -> Just (irStatus, paymentMark row)
_ -> Nothing
where
-- The counter a hold watches starts at zero, so a payment recorded before this request arrived cannot wake it.
samePayment = case paidParam req of
Nothing -> True
Just seen -> seen == paymentMark row
readSeen :: IO Seen
readSeen = maybe (ISOpen, ("", False)) (\row -> (irStatus row, paymentMark row)) <$> getInvoice weStore invId
-- | Monero reports a payment as confirming while its figures are zero, so the verdict counts as much as the figure.
paymentMark :: InvoiceRow -> (Text, Bool)
paymentMark InvoiceRow {irPayment} =
(maybe "" (fromMaybe "" . ipCryptoPaid) irPayment, maybe False ipPaidInFull irPayment)
-- | The provider is cancelled first, because cancelling our record first would leave an address the buyer can still pay into with nothing watching it.
cancelInvoiceHandler :: WebEnv -> InvoiceId -> Respond -> IO ResponseReceived
cancelInvoiceHandler env@WebEnv {weStore} invId respond =
getInvoice weStore invId >>= \case
Nothing -> respond notFound
Just InvoiceRow {irStatus} | irStatus /= ISOpen -> respond (jsonError status409 "not_open")
Just row | funded row -> respond (jsonError status409 "funded")
Just InvoiceRow {irProvider, irProviderRef} -> case providerNamed env irProvider of
Nothing -> respond providerUnavailable
Just Provider {pCancelInvoice} ->
pCancelInvoice irProviderRef >>= \case
Left (ProviderError e) -> do
logError $ "cancel order " <> irProviderRef <> ": " <> e
respond providerUnavailable
Right () -> finish
where
funded InvoiceRow {irPayment} = maybe False paymentHolds irPayment
finish = do
now <- getCurrentTime
cancelled <- cancelOpenInvoice weStore invId now
when cancelled $ atomically $ publish (weWaiters env) invId ISExpired
getInvoice weStore invId >>= \case
Nothing -> respond notFound
Just row -> respond (jsonResponse status200 (invoiceView (confirmationsFor env) row))
paidParam :: Request -> Maybe (Text, Bool)
paidParam req = case lookup "seenPaid" (queryString req) of
Just raw -> (\seen -> (seen, fullParam)) <$> either (const Nothing) Just (decodeUtf8' (fromMaybe "" raw))
Nothing -> Nothing
where
fullParam = lookup "seenFull" (queryString req) == Just (Just "1")
waitParam :: Request -> Maybe InvoiceStatus
waitParam req = case lookup "wait" (queryString req) of
Just (Just raw) -> either (const Nothing) textToInvoiceStatus (decodeUtf8' raw)
_ -> Nothing
-- | Greenfield reports no confirmation count, so this comes from the store's speed policy.
confirmationsFor :: WebEnv -> Maybe Int
confirmationsFor WebEnv {weConfig} = speedPolicyConfirmations . bSpeedPolicy <$> btcpay weConfig
-- | BTCPay's own store setting, whose numbering is not in speed order.
speedPolicyConfirmations :: SpeedPolicy -> Int
speedPolicyConfirmations = \case
HighSpeed -> 0
MediumSpeed -> 1
LowMediumSpeed -> 2
LowSpeed -> 6
invoiceView :: Maybe Int -> InvoiceRow -> J.Value
invoiceView confirmations InvoiceRow {irBadgeType, irMonths, irAmount, irCurrency, irDestination, irExpiresAt, irStatus, irPayment} =
J.object $
[ "status" .= invoiceStatusText irStatus,
"badgeType" .= (textEncode irBadgeType :: Text),
"months" .= irMonths,
"amount" .= amountJSON irAmount,
"currency" .= irCurrency,
"expiresAt" .= irExpiresAt
]
<> destinationPairs confirmations irDestination
<> paidPairs
where
paidPairs = maybe [] paymentPairs irPayment
paymentPairs p =
concat
[ maybe [] (\a -> ["amountPaid" .= amountJSON a]) (ipAmount p),
maybe [] (\a -> ["cryptoAmountPaid" .= a]) (ipCryptoPaid p),
maybe [] (\a -> ["cryptoAmountDue" .= a]) (ipCryptoDue p),
["paidInFull" .= ipPaidInFull p],
["settledAt" .= ipUpdatedAt p | irStatus == ISPaid]
]
destinationPairs :: Maybe Int -> ServicePaymentDestination -> [Pair]
destinationPairs confirmations = \case
SPDCard _ url -> ["clientSecret" .= url]
SPDCrypto currency address cryptoAmount ->
[ "address" .= address,
"cryptoAmount" .= cryptoAmount,
"cryptoCurrency" .= cryptoCurrencyText currency
]
<> maybe [] (\n -> ["requiredConfirmations" .= n]) confirmations
amountJSON :: CurrencyAmount -> J.Value
amountJSON (CurrencyAmount a) = J.toJSON a
data CreateRequest = CreateRequest
{ crPriceId :: BadgePriceId,
crOfferId :: Maybe BadgeOfferId,
crMethod :: ServicePaymentMethod,
crCodeHash :: ByteString
}
createInvoiceHandler :: WebEnv -> Request -> Respond -> IO ResponseReceived
createInvoiceHandler env@WebEnv {weStore} req respond =
readBoundedBody maxBodyBytes req >>= \case
Nothing -> refuse "body over the size limit" badRequest
Just body -> case parseCreateRequest body of
Nothing -> refuse "malformed body" badRequest
Just cr@CreateRequest {crPriceId, crOfferId, crMethod, crCodeHash} -> do
(prices, offers) <- readCatalogRows weStore
case priceOffer prices offers crPriceId crOfferId of
Left reason -> refuse (tshow reason) catalogChanged
Right priced ->
codeHashExists weStore crCodeHash >>= \case
True -> refuse "code hash already sold" codeConflict
False -> case providerFor env crMethod of
Nothing -> refuse ("no provider configured for " <> tshow crMethod) providerUnavailable
Just provider -> createAtProvider env provider cr priced respond
where
refuse :: Text -> Response -> IO ResponseReceived
refuse why response = logInfo ("POST /api/invoice refused: " <> why) >> respond response
-- | Once the provider call succeeds an invoice exists at BTCPay, and any path below that does not write our rows leaves it stranded.
createAtProvider :: WebEnv -> Provider -> CreateRequest -> PricedOffer -> Respond -> IO ResponseReceived
createAtProvider WebEnv {weStore, weConfig} provider CreateRequest {crPriceId, crOfferId, crMethod, crCodeHash} priced respond =
do
invId <- newInvoiceId
-- truncate once, so the response, the row and the provider get the same value
now <- truncateToSecond <$> getCurrentTime
let expiresAt = addUTCTime (invoiceWindow weConfig crMethod) now
draft = OrderDraft {odAmount = poAmount priced, odCurrency = poCurrency priced}
pCreateInvoice provider crMethod draft >>= \case
Left (ProviderError e) -> failed ("provider refused to create an invoice: " <> e) providerUnavailable
Right ProviderInvoice {piProviderRef, piDestination} -> do
let atProvider = "providerRef " <> piProviderRef
ni =
NewInvoice
{ niInvoiceId = invId,
niProviderRef = piProviderRef,
niCodeHash = crCodeHash,
niPriceId = crPriceId,
niOfferId = crOfferId,
niBadgeType = poBadgeType priced,
niMonths = poMonths priced,
niPrice = poPrice priced,
niAmount = poAmount priced,
niCurrency = poCurrency priced,
niProvider = pProvider provider,
niDestination = piDestination,
niExpiresAt = expiresAt,
niCreatedAt = now
}
createInvoiceRows weStore ni >>= \case
Left CECodeConflict -> logInfo ("POST /api/invoice: code hash lost the race, invoice abandoned at the provider (" <> atProvider <> ")") >> respond codeConflict
Left e -> failed ("invoice rows not written, invoice abandoned at the provider (" <> atProvider <> "): " <> tshow e) internalError
Right () -> respond (jsonResponse status200 (createdInvoice invId priced expiresAt piDestination))
where
failed :: Text -> Response -> IO ResponseReceived
failed why response = logError ("POST /api/invoice failed: " <> why) >> respond response
createdInvoice :: InvoiceId -> PricedOffer -> UTCTime -> ServicePaymentDestination -> J.Value
createdInvoice (InvoiceId invId) PricedOffer {poBadgeType, poMonths, poAmount, poCurrency} expiresAt destination =
J.object $
[ "invoiceId" .= invId,
"badgeType" .= (textEncode poBadgeType :: Text),
"months" .= poMonths,
"amount" .= amountJSON poAmount,
"currency" .= poCurrency,
"expiresAt" .= expiresAt
]
<> destinationPairs Nothing destination
secondsPerMinute :: Int
secondsPerMinute = 60
-- | The expiry shown and stored must come from the same key the provider sets the invoice's real expiry from.
invoiceWindow :: ServiceConfig -> ServicePaymentMethod -> NominalDiffTime
invoiceWindow cfg = \case
SPMCard CPStripe -> minutes (maybe defaultSessionMinutes sSessionMinutes (stripe cfg))
SPMCrypto _ -> minutes (maybe defaultExpiryMinutes bExpiryMinutes (btcpay cfg))
where
minutes n = fromIntegral (secondsPerMinute * n)
providerFor :: WebEnv -> ServicePaymentMethod -> Maybe Provider
providerFor env method = providerNamed env (providerOf method)
providerNamed :: WebEnv -> PaymentProvider -> Maybe Provider
providerNamed WebEnv {weProviders} provider = find ((== provider) . pProvider) weProviders
providerOf :: ServicePaymentMethod -> PaymentProvider
providerOf = \case
SPMCard CPStripe -> PPStripe
SPMCrypto _ -> PPCrypto
-- | Returns the bytes exactly as they arrived, since the webhook signature is over those bytes.
readBoundedBody :: Int -> Request -> IO (Maybe LB.ByteString)
readBoundedBody cap req = go 0 []
where
go :: Int -> [ByteString] -> IO (Maybe LB.ByteString)
go read' acc = do
chunk <- getRequestBodyChunk req
if BS.null chunk
then pure (Just (LB.fromChunks (reverse acc)))
else
let read'' = read' + BS.length chunk
in if read'' > cap then pure Nothing else go read'' (chunk : acc)
parseCreateRequest :: LB.ByteString -> Maybe CreateRequest
parseCreateRequest body = case J.decode body of
Just (J.Object o) -> do
crPriceId <- BadgePriceId <$> textField o "priceId"
crOfferId <- offerField o
crMethod <- methodFromText =<< textField o "method"
crCodeHash <- parseCodeHash =<< textField o "codeHash"
pure CreateRequest {crPriceId, crOfferId, crMethod, crCodeHash}
_ -> Nothing
where
textField o k = case KM.lookup k o of
Just (J.String t) | not (T.null t) -> Just t
_ -> Nothing
offerField o = case KM.lookup "offerId" o of
Nothing -> Just Nothing
Just J.Null -> Just Nothing
Just (J.String t) | not (T.null t) -> Just (Just (BadgeOfferId t))
_ -> Nothing
methodFromText :: Text -> Maybe ServicePaymentMethod
methodFromText = \case
"card" -> Just (SPMCard CPStripe)
"btc" -> Just (SPMCrypto CCBtc)
"xmr" -> Just (SPMCrypto CCXmr)
_ -> Nothing
-- | The last character of a 43-character base64 string has two bits no digest byte uses, so a lax
-- decoder would accept four spellings of the same digest, which the re-encode check rejects.
parseCodeHash :: Text -> Maybe ByteString
parseCodeHash t
| T.length t /= codeHashChars = Nothing
| otherwise = case B64U.decode (encodeUtf8 (t <> "=")) of
Right bytes | BS.length bytes == sha256Bytes, canonical bytes == t -> Just bytes
_ -> Nothing
where
canonical = T.filter (/= '=') . safeDecodeUtf8 . B64U.encode
-- | A provider retries a delivery it sees fail, so anything thrown below is caught and answered 200 anyway.
webhookHandler :: WebEnv -> PaymentProvider -> Text -> Request -> Respond -> IO ResponseReceived
webhookHandler env@WebEnv {weStore, weHints} provider route req respond =
E.try deliver >>= \case
Right received -> pure received
Left e -> case E.fromException e of
Just async' -> E.throwIO (async' :: E.SomeAsyncException)
Nothing -> do
logError (route <> ": the delivery could not be handled, so no read was queued: " <> tshow (e :: E.SomeException))
respond webhookOk
where
deliver :: IO ResponseReceived
deliver = case providerNamed env provider of
Nothing -> do
logWarn (route <> ": no " <> tshow provider <> " adapter is configured, so every delivery is refused")
respond webhookRefused
Just p ->
readBoundedBody maxWebhookBytes req >>= \case
Nothing -> logInfo (route <> ": body over the " <> tshow maxWebhookBytes <> "-byte cap") >> respond webhookTooLarge
Just body -> do
now <- getCurrentTime
-- a provider signs the exact bytes it sent, so parsing and re-encoding here would fail the signature on every event
case pVerifyWebhook p now (requestHeaders req) (LB.toStrict body) of
Left (WebhookError e) -> refuse e
Left (WebhookStale e) -> logWarn (route <> " refused: " <> e) >> respond webhookRefused
Right Nothing -> ignore body "nothing this service acts on"
Right (Just ref) -> resolve body ref
refuse :: Text -> IO ResponseReceived
refuse why = logInfo (route <> " refused: " <> why) >> respond webhookRefused
ignore :: LB.ByteString -> Text -> IO ResponseReceived
ignore body why = logEvent body why >> respond webhookOk
logEvent :: LB.ByteString -> Text -> IO ()
logEvent body what = case eventTypeOf body of
Just t -> logInfo (route <> ": " <> t <> ", " <> what)
Nothing -> logWarn (route <> ": " <> noEventType <> ", " <> what)
noEventType :: Text
noEventType = "a verified payload with no readable \"type\""
resolve :: LB.ByteString -> Text -> IO ResponseReceived
resolve body ref =
getInvoiceByProviderRef weStore ref >>= \case
Nothing -> ignore body ("no invoice holds provider_ref " <> ref)
Just InvoiceRow {irProvider}
-- provider_ref is unique table-wide, not per provider, so without this a collision could credit the wrong order
| irProvider /= provider -> ignore body ("provider_ref " <> ref <> " belongs to " <> tshow irProvider)
| otherwise -> queue body ref
queue :: LB.ByteString -> Text -> IO ResponseReceived
queue body ref = do
queued <- queueReadHint weHints ref
if queued
then logEvent body ("queued a read of " <> ref)
else -- not an error, because the next pass finds this invoice anyway
logWarn (route <> ": " <> fromMaybe noEventType (eventTypeOf body) <> ", the read queue is full, so the read of " <> ref <> " waits for the next pass")
respond webhookOk
-- | For the log only. A rename of the @type@ field would make every delivery look successful.
eventTypeOf :: LB.ByteString -> Maybe Text
eventTypeOf body = case J.decode body of
Just (J.Object o) | Just (J.String t) <- KM.lookup "type" o -> Just t
_ -> Nothing
webSettings :: ListenerConfig -> Warp.Settings
webSettings ListenerConfig {lHost, lPort} =
Warp.setHost (fromString (T.unpack lHost))
. Warp.setPort lPort
. Warp.setOnExceptionResponse (const internalError)
$ Warp.defaultSettings
runWebListener :: WebEnv -> IO ()
runWebListener env = do
warnIfHeaderTrustIsExposed (listenerConfig env)
Warp.runSettings (webSettings (listenerConfig env)) (webApp env)
warnIfHeaderTrustIsExposed :: ListenerConfig -> IO ()
warnIfHeaderTrustIsExposed ListenerConfig {lHost, lTrustForwardedFor} =
when (lTrustForwardedFor && not (isLoopbackHost lHost)) $
logWarn ("badge service: trust_forwarded_for is on and the listener binds " <> lHost <> "; every caller can then choose its own rate limit bucket unless a proxy sets the header")
isLoopbackHost :: Text -> Bool
isLoopbackHost h = h `elem` ["127.0.0.1", "::1", "localhost"]
@@ -0,0 +1,36 @@
{
"_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status Expired -- the payment window closed -- from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.",
"response": {
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": ["BTC-CHAIN"],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "GmpP7VDp3jj7HZeJVQpSNP",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Expired",
"additionalStatus": "None",
"availableStatusesForManualMarking": ["Settled", "Invalid"],
"archived": false
}
}
@@ -0,0 +1,36 @@
{
"_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status Invalid -- the invoice was marked invalid, which this design treats as expired -- from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.",
"response": {
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": ["BTC-CHAIN"],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "GmpP7VDp3jj7HZeJVQpSNP",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Invalid",
"additionalStatus": "None",
"availableStatusesForManualMarking": ["Settled", "Invalid"],
"archived": false
}
}
@@ -0,0 +1,133 @@
{
"_fixture": "The same list response carrying an invoice paid over Lightning (BTC-LN), which this build does not know: switching Lightning on at the store is all it takes. Hand-written from the documented schema. The alien entry must be SKIPPED and the healthy one in the SAME response must still settle.",
"response": [
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "LightningInvoiceRefDD",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/LightningInvoiceRefDD",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Settled",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "BTC-LN",
"currency": "BTC",
"destination": "lnbc500u1pexampleinvoicebolt11string",
"paymentLink": "lightning:lnbc500u1pexampleinvoicebolt11string",
"rate": "108000.12",
"paymentMethodPaid": "0.00050000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "d7a41e0b93c85f26a1804dbe73f52c9a6081ef34d25b7a90c6183fe45b027dac",
"receivedDate": 1700003600,
"value": "0.00050000",
"fee": "0.00000000",
"status": "Settled",
"destination": "lnbc500u1pexampleinvoicebolt11string"
}
],
"additionalData": {}
}
]
},
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "SettledInvoiceRefAAAA",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/SettledInvoiceRefAAAA",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Settled",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00050000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0",
"receivedDate": 1700003600,
"value": "0.00050000",
"fee": "0.00000000",
"status": "Settled",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
}
],
"additionalData": {}
}
]
}
]
}
@@ -0,0 +1,133 @@
{
"_fixture": "The same list response carrying an invoice whose `status` this build does not know -- the store enumerates every invoice, ours and not. Hand-written from the documented schema, since a real BTCPay will not produce a status on demand. The alien entry must be SKIPPED and the healthy one in the SAME response must still settle.",
"response": [
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "AlienStatusInvoiceRef",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/AlienStatusInvoiceRef",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Frobnicated",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00050000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0",
"receivedDate": 1700003600,
"value": "0.00050000",
"fee": "0.00000000",
"status": "Settled",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
}
],
"additionalData": {}
}
]
},
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "SettledInvoiceRefAAAA",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/SettledInvoiceRefAAAA",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Settled",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00050000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0",
"receivedDate": 1700003600,
"value": "0.00050000",
"fee": "0.00000000",
"status": "Settled",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
}
],
"additionalData": {}
}
]
}
]
}
@@ -0,0 +1,132 @@
{
"_fixture": "A list response carrying an entry with no `id`. Hand-written from the documented schema, where `id` is required. This must FAIL the pass: an invoice the pass cannot even name has no ref to skip and none to log.",
"response": [
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "SettledInvoiceRefAAAA",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/SettledInvoiceRefAAAA",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Settled",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00050000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0",
"receivedDate": 1700003600,
"value": "0.00050000",
"fee": "0.00000000",
"status": "Settled",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
}
],
"additionalData": {}
}
]
},
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/x",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Settled",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00050000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0",
"receivedDate": 1700003600,
"value": "0.00050000",
"fee": "0.00000000",
"status": "Settled",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
}
],
"additionalData": {}
}
]
}
]
}
@@ -0,0 +1,43 @@
{
"_fixture": "A list response with `paymentMethods` ABSENT, which is what a server that did not honour includePaymentMethods sends. Hand-written from the documented schema. This must FAIL the pass: skipping it would answer `Right []` and report health for as long as nothing ever settled.",
"response": [
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "NoMethodsInvoiceRefEE",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/NoMethodsInvoiceRefEE",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Settled",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false
}
]
}
@@ -0,0 +1,188 @@
{
"_fixture": "GET /api/v1/stores/{storeId}/invoices?includePaymentMethods=true&startDate=... -- InvoiceData[] with the payment methods inline, from BTCPay's DOCUMENTED Greenfield schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. Three invoices: one Settled, one Processing, and one New with nothing received -- the last of which the pass must report as unmoved rather than omit by accident.",
"response": [
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "SettledInvoiceRefAAAA",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/SettledInvoiceRefAAAA",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Settled",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00050000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0",
"receivedDate": 1700003600,
"value": "0.00050000",
"fee": "0.00000000",
"status": "Settled",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
}
],
"additionalData": {}
}
]
},
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"XMR-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "ProcessingInvoiceRefB",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/ProcessingInvoiceRefB",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Processing",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "XMR-CHAIN",
"currency": "XMR",
"destination": "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
"paymentLink": "monero:44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A?tx_amount=0.32095000",
"rate": "168.25",
"paymentMethodPaid": "0.32095000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.32095000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "6f2c1b0a9e8d7c6b5a4938271605f4e3d2c1b0a9e8d7c6b5a4938271605f4e3d",
"receivedDate": 1700003000,
"value": "0.32095000",
"fee": "0.00000000",
"status": "Processing",
"destination": "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A"
}
],
"additionalData": {}
}
]
},
{
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": [
"BTC-CHAIN"
],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "UntouchedInvoiceRefCC",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/UntouchedInvoiceRefCC",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "New",
"additionalStatus": "None",
"availableStatusesForManualMarking": [
"Settled",
"Invalid"
],
"archived": false,
"paymentMethods": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00000000",
"totalPaid": "0.00000000",
"due": "0.00050000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [],
"additionalData": {}
}
]
}
]
}
@@ -0,0 +1,36 @@
{
"_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status New, from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.",
"response": {
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": ["BTC-CHAIN"],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "GmpP7VDp3jj7HZeJVQpSNP",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "New",
"additionalStatus": "None",
"availableStatusesForManualMarking": ["Settled", "Invalid"],
"archived": false
}
}
@@ -0,0 +1,36 @@
{
"_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status Processing -- InvoiceProcessing has fired: the payment is seen and is waiting for the confirmations `checkout.speedPolicy` requires -- from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.",
"response": {
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": ["BTC-CHAIN"],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "GmpP7VDp3jj7HZeJVQpSNP",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Processing",
"additionalStatus": "None",
"availableStatusesForManualMarking": ["Settled", "Invalid"],
"archived": false
}
}
@@ -0,0 +1,36 @@
{
"_fixture": "POST /api/v1/stores/{storeId}/invoices and GET .../invoices/{invoiceId} -- InvoiceData with status Settled -- the invoice is paid in full -- from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches `id`, `status` and `additionalStatus`; everything else is served as written here.",
"response": {
"metadata": {},
"checkout": {
"speedPolicy": "MediumSpeed",
"paymentMethods": ["BTC-CHAIN"],
"defaultPaymentMethod": null,
"expirationMinutes": 60,
"monitoringMinutes": 4320,
"paymentTolerance": 0.5,
"redirectURL": null,
"redirectAutomatically": false,
"requiresRefundEmail": false,
"defaultLanguage": null
},
"receipt": {
"enabled": null,
"showQR": null,
"showPayments": null
},
"id": "GmpP7VDp3jj7HZeJVQpSNP",
"storeId": "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT",
"amount": "54.00",
"currency": "USD",
"type": "Standard",
"checkoutLink": "https://btcpay.example.org/i/GmpP7VDp3jj7HZeJVQpSNP",
"createdTime": 1700000000,
"expirationTime": 1700003600,
"monitoringExpiration": 1700259200,
"status": "Settled",
"additionalStatus": "None",
"availableStatusesForManualMarking": ["Settled", "Invalid"],
"archived": false
}
}
@@ -0,0 +1,29 @@
{
"_fixture": "GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods -- InvoicePaymentMethodDataModel[], from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. `totalPaid` is deliberately NOT equal to `paymentMethodPaid` -- at this rate reading the wrong one would report $1,080,001.20 instead of $54.00. The fake patches `paymentMethodPaid`, and serves `payments` as [] when nothing was paid.",
"response": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00050000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "8e3a1f0c9d2b4a5768f90e1c2d3b4a5968f70e1c2d3b4a5968f70e1c2d3b4a59-0",
"receivedDate": 1700003600,
"value": "0.00050000",
"fee": "0.00000000",
"status": "Settled",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
}
],
"additionalData": {}
}
]
}
@@ -0,0 +1,19 @@
{
"_fixture": "GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods -- a BTC-CHAIN method with no `amount` -- the crypto figure the payment screen renders, absent. Hand-written from the documented schema; a real BTCPay will not produce it on demand. A create that cannot learn where to pay must be a ProviderError naming the missing field, and write nothing -- the invoice exists at the provider and is abandoned there.",
"response": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"destination": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
"paymentLink": "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.00050000",
"rate": "108000.12",
"paymentMethodPaid": "0.00000000",
"totalPaid": "0.00000000",
"due": "0.00050000",
"networkFee": "0.00000500",
"activated": true,
"payments": [],
"additionalData": {}
}
]
}
@@ -0,0 +1,18 @@
{
"_fixture": "GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods -- a BTC-CHAIN method with no `destination` yet -- the shape a payment method has before it is activated and given an address. Hand-written from the documented schema; a real BTCPay will not produce it on demand. A create that cannot learn where to pay must be a ProviderError naming the missing field, and write nothing -- the invoice exists at the provider and is abandoned there.",
"response": [
{
"paymentMethodId": "BTC-CHAIN",
"currency": "BTC",
"rate": "108000.12",
"paymentMethodPaid": "0.00000000",
"totalPaid": "0.00000000",
"due": "0.00050000",
"amount": "0.00050000",
"networkFee": "0.00000500",
"activated": false,
"payments": [],
"additionalData": {}
}
]
}
@@ -0,0 +1,29 @@
{
"_fixture": "GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods -- InvoicePaymentMethodDataModel[] for the Monero PLUGIN, from the same documented shape. NOT captured from a live instance -- the plugin is the likeliest of these files to diverge, XMR coming from btcpayserver-monero-plugin rather than BTCPay itself, and the likeliest to need replacing with a recorded body. `totalPaid` is deliberately NOT equal to `paymentMethodPaid`.",
"response": [
{
"paymentMethodId": "XMR-CHAIN",
"currency": "XMR",
"destination": "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
"paymentLink": "monero:44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A?tx_amount=0.32095000",
"rate": "168.25",
"paymentMethodPaid": "0.32095000",
"totalPaid": "9.99999999",
"due": "0.00000000",
"amount": "0.32095000",
"networkFee": "0.00000500",
"activated": true,
"payments": [
{
"id": "6f2c1b0a9e8d7c6b5a4938271605f4e3d2c1b0a9e8d7c6b5a4938271605f4e3d",
"receivedDate": 1700003600,
"value": "0.32095000",
"fee": "0.00000000",
"status": "Settled",
"destination": "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A"
}
],
"additionalData": {}
}
]
}
@@ -0,0 +1,21 @@
{
"_fixture": "GET /api/v1/stores/{storeId}/payment-methods -- StorePaymentMethodDataList, from BTCPay's DOCUMENTED Greenfield schema (BTCPay 2.x naming, where a payment method id is CODE-CHAIN). NOT captured from a live instance; replace `response` with a recorded body and rewrite this line.",
"response": [
{
"paymentMethodId": "BTC-CHAIN",
"enabled": true,
"config": {
"accountDerivation": "xpub661MyMwAqRbcExampleAccountDerivation-[p2wpkh]",
"label": "",
"accountKeyPath": ""
}
},
{
"paymentMethodId": "XMR-CHAIN",
"enabled": true,
"config": {
"accountIndex": 0
}
}
]
}
@@ -0,0 +1,12 @@
{
"_fixture": "GET /v1/payment_intents/{id} for a canceled intent (status canceled), from Stripe's documented API schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. A canceled intent captured nothing, so amount_received is zero.",
"response": {
"id": "pi_test_canceled",
"object": "payment_intent",
"amount": 5400,
"amount_received": 0,
"currency": "usd",
"status": "canceled",
"latest_charge": null
}
}
@@ -0,0 +1,12 @@
{
"_fixture": "GET /v1/payment_intents?created[gte]=<unix>&limit=<n>, a page whose LAST row carries no id, from Stripe's documented API schema. NOT captured from a live instance. Served with a small page size so the id-less row ends a page while more remain, leaving no starting_after to continue from. pi_test_a settles, pi_test_c sits on the unreachable next page.",
"response": {
"object": "list",
"has_more": false,
"data": [
{"id": "pi_test_a", "object": "payment_intent", "status": "succeeded", "amount": 5400, "amount_received": 5400, "currency": "usd"},
{"object": "payment_intent", "status": "requires_payment_method", "amount": 5400, "amount_received": 0, "currency": "usd"},
{"id": "pi_test_c", "object": "payment_intent", "status": "canceled", "amount": 5400, "amount_received": 0, "currency": "usd"}
]
}
}
@@ -0,0 +1,13 @@
{
"_fixture": "GET /v1/payment_intents?created[gte]=<unix>&limit=<n>, a page of PaymentIntents created within the settle window, from Stripe's documented API schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. List rows carry NO expanded latest_charge, so a succeeded row dates its settlement at read time. Rows exercise the mapping: pi_test_open still open (no signal), pi_test_a succeeded, pi_test_b canceled, and one row with no id for the skip path. The fake serves data verbatim and paginates when _paging is set.",
"response": {
"object": "list",
"has_more": false,
"data": [
{"id": "pi_test_open", "object": "payment_intent", "status": "requires_payment_method", "amount": 5400, "amount_received": 0, "currency": "usd"},
{"id": "pi_test_a", "object": "payment_intent", "status": "succeeded", "amount": 5400, "amount_received": 5400, "currency": "usd"},
{"id": "pi_test_b", "object": "payment_intent", "status": "canceled", "amount": 5400, "amount_received": 0, "currency": "usd"},
{"object": "payment_intent", "status": "requires_payment_method", "amount": 5400, "amount_received": 0, "currency": "usd"}
]
}
}
@@ -0,0 +1,13 @@
{
"_fixture": "POST /v1/payment_intents and GET /v1/payment_intents/{id} for an unpaid intent (status requires_payment_method), from Stripe's documented API schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches id, client_secret, status, amount_received and currency; everything else is served as written.",
"response": {
"id": "pi_test_open",
"object": "payment_intent",
"amount": 5400,
"amount_received": 0,
"currency": "usd",
"client_secret": "pi_test_open_secret",
"status": "requires_payment_method",
"latest_charge": null
}
}
@@ -0,0 +1,18 @@
{
"_fixture": "GET /v1/payment_intents/{id}?expand[]=latest_charge for a paid intent (status succeeded), from Stripe's documented API schema. NOT captured from a live instance; replace `response` with a recorded body and rewrite this line. The fake patches id, status, amount_received and currency; latest_charge.created dates the settlement.",
"response": {
"id": "pi_test_settled",
"object": "payment_intent",
"amount": 5400,
"amount_received": 5400,
"currency": "usd",
"status": "succeeded",
"latest_charge": {
"id": "ch_test_x",
"object": "charge",
"created": 1700000000,
"paid": true,
"amount_captured": 5400
}
}
}
@@ -0,0 +1,3 @@
node_modules/
build/
dist/
+301
View File
@@ -0,0 +1,301 @@
# SimpleX badge codes — web
The buyer-facing page for badge codes: choose a tier and duration, pay by
card, BTC or XMR, and receive a code. Design and rationale are in
[`plans/badges-codes/2026-08-27-badge-codes.md`](../../../plans/badges-codes/2026-08-27-badge-codes.md);
the implementation plan for this app is
[`plans/badges-codes/2026-08-28-web-implementation.md`](../../../plans/badges-codes/2026-08-28-web-implementation.md).
This file covers how to build, test and run it.
No runtime or dev dependencies beyond `typescript` and `@types/node`.
Stripe.js is loaded at runtime from `js.stripe.com`, never bundled or
installed.
## Build and test
```
npm install
npm run build
npm test
```
`npm run build` clears `build/` and runs `tsc` into it (gitignored), then
`build.js`, which content-hashes the compiled modules, `styles.css` and the images,
copies them to `dist/assets/<hash>/`, and writes that hash into
`public/index.html` and `public/sw.js`. `dist/` (gitignored) is the deploy
tree: `index.html`, `sw.js` and `assets/<hash>/`, nothing else.
`npm test` compiles and runs the suite (`node --test` over
compiled output) but does **not** run `build.js`. One of those tests is a
tripwire: it recomputes the hash from the freshly compiled modules and fails
if the hash named in `public/index.html` or `public/sw.js` does not match.
**If you edit anything under `src/`, run `npm run build` and commit the
regenerated `public/index.html` and `public/sw.js`.** Skipping this is the
most likely way to break the suite — `npm test` will tell you, but the fix is
`npm run build`, not the test.
Both scripts clear `build/` first. `tsc` leaves the output of a module you rename or delete
behind, `build.js` hashes every `.js` it finds there, and the result is a phantom module in the
build that the tripwire would tell you to commit.
## Layout
Only `src/main.ts` and `src/stripe.ts` touch `window`, `location`, `history`
or `localStorage`. `src/screens.ts` builds the DOM, and `src/qr.ts` and
`src/icons.ts` reach for `document` only through `createElementNS`, to build
inline SVG. Nothing else outside those five modules touches the DOM. Every other module
is plain TypeScript, tested directly in Node.
| Module | Owns |
|---|---|
| `domain.ts` | The words the app is written in: steps, themes, order statuses, methods, chains |
| `catalog.ts` | Prices and offers compiled into the page at build time |
| `codes.ts` | The badge code alphabet and check character |
| `parse.ts` | Readers for data the page did not produce, each answering the value or undefined |
| `format.ts` | Money, countdowns and elapsed time as the words a screen prints |
| `store.ts` | The three `localStorage` keys — session, orders and the chosen theme |
| `order.ts` | What an order's state means: what to keep from a response, which screen it selects, and its history row |
| `routing.ts` | Reading `?order=` and the store into what to render |
| `api.ts` | The three routes — create, cancel and read — and the long-poll wait loop, with `fetch`, sleep and the clock injected |
| `flow.ts` | Payment flow control logic — pure, no DOM, no globals |
| `stripe.ts` | Loading Stripe.js, mounting the Payment Element, confirming, and the no-key stand-in |
| `icons.ts` | The badge art, the payment marks and the hamburger, built with `createElementNS`. The brand mark is NOT here: it is a served file |
| `qr.ts` | A QR encoder written for this page, with no dependency, no network and no raster |
| `screens.ts` | Every screen of the spec, and the header and its menu, built node by node — markup is never assigned from a string |
| `main.ts` | Wiring: DOM events in, `flow.ts` calls out, `screens.ts` renders |
## Running the real service against this build
**`mock/server.py` is not the service.** The service is the Haskell
executable `simplex-badge-service`, in `apps/simplex-badge-service/`, and
`dist/` is the directory its listener serves. Nothing about the page changes
between the two — the service matches this build, never the reverse.
```
# 1. build the page. dist/ is the deploy tree: index.html, sw.js, assets/<hash>/
cd apps/simplex-badge-service/web
npm install && npm run build
cd ../../..
# 2. configuration, from the committed example. Copy it IN
# PLACE, beside the example: that path is the one .gitignore covers, and this
# file is about to hold a real api_key and webhook_secret.
cd apps/simplex-badge-service
cp badge_service.ini.example badge_service.ini
cd ../..
# static_dir = ./apps/simplex-badge-service/web/dist <- the build above
# [btcpay] = a real store's host, api_key, store_id and webhook_secret,
# or delete the whole section to disable BTC and XMR (the provider-unavailable screen)
# [issuer] = uncomment the section and put a real issuer secret in private_key,
# with its index in index. It has to be a key whose public half already
# ships in the apps: startup checks the secret against `badgePublicKeys`
# at that index, so a fresh `simplex-chat badge keygen` pair is refused.
# 3. run it. Without --service-config the web listener does not start at all.
# An issuer key is required even though nothing in the checkout path uses it:
# the same process also answers redemption requests, and it refuses to start
# without a key it could sign a credential with. Put it in the ini's [issuer]
# section, or pass --issuer-key-idx and --issuer-secret, which override it.
# `simplex-chat badge keygen` prints a pair.
cabal run simplex-badge-service -- \
--service-config apps/simplex-badge-service/badge_service.ini
```
Then open the `[listener]` host and port — `http://127.0.0.1:8080/` as the
example ships. `static_dir` is relative to the working directory, so run from
the repository root or make it absolute.
**The executable is the whole badge service, not just this listener.**
`badgeService` starts `simplexChatCore` unconditionally and the web listener
is one lane beside the chat one, so a local run opens or creates a chat
database (`printDbOpts` names the path at startup) and does agent network
work. `--no-address` skips creating the service's contact address on first
start, which a run of the web listener alone does not need.
*These three steps are read out of `Options.hs`, `Service.hs`, `Config.hs` and
`badge_service.ini.example` rather than from a run — driving them end to end
needs a chat database and a real BTCPay store.*
What the mock cannot stand in for:
| | `mock/server.py` | `simplex-badge-service` |
|---|---|---|
| Invoices | invented in memory | created at BTCPay over Greenfield |
| Payment detected by | `POST /control/settle/:id`, an endpoint that exists nowhere else | a poller reading the provider, which is the only thing that carries authority |
| Webhooks | none | `POST /webhooks/btcpay`, signature-verified, a latency hint and nothing more |
| Persistence | none | the chat database, under `sx_badge_service_*` |
| Codes | invented | written unpaid at checkout, marked paid by settlement |
The Haskell side's own end-to-end coverage of that lane — checkout, payment,
polling, partial payment, expiry, late settlement and replay, all against a
fake Greenfield — is `tests/Bots/BadgeService/WebTests.hs`:
```
cabal test --test-options='-m "/SimpleX badge service/"'
```
## Running the mock
`mock/server.py` stands in for the Haskell service, Stripe and BTCPay, so the
whole browser flow can be driven with no real backend. **It is a browser-only
test fixture and not the service**: no signatures, no persistence, no real
money, no provider, and not a specification of what ships. Standard library
only.
```
python3 mock/server.py --port 8099
```
It serves `public/` and `dist/`, and adds:
| Endpoint | Behaviour |
|---|---|
| `POST /api/invoice` | Creates an invoice. Rejects a repeated `codeHash` with `409 code_conflict`. |
| `GET /api/invoice/:id?wait=<status>&seenPaid=<figure>&seenFull=<0\|1>` | Long-polls: holds while the invoice's status is still `<status>` **and** its payment is the one the page says it has rendered, up to `MOCK_HOLD_SECONDS` (default 30). A payment the page has not seen answers at once — the provider's verdict counts as much as the figure, since Monero reports an invoice as confirming while its figures are still zero. A request that omits `seenPaid` holds on the status alone. |
| `POST /api/invoice/:id/cancel` | Expires an open invoice. Refuses a settled one with `409 not_open` and a funded one with `409 funded`, as the service does. |
| `POST /control/settle/:id` | Marks the invoice paid — stands in for a provider webhook. |
| `POST /control/expire/:id` | Marks it expired. |
| `POST /control/partial/:id` | Records a partial payment, half the amount due, and the remainder the provider would still ask for. |
| `POST /control/confirming/:id` | Records the full amount as arrived with the invoice still open — the screen that waits for confirmations. |
| `POST /control/verdict/:id` | Records the provider's verdict alone, with no figure — how Monero reports a payment it is still confirming. |
### Driving a purchase by hand
```
python3 mock/server.py --port 8099 &
curl -s -X POST http://localhost:8099/api/invoice -H 'content-type: application/json' \
-d '{"codeHash":"<43 base64url chars>","priceId":"price_supporter","offerId":"offer_3m","method":"btc"}'
# => {"invoiceId": "...", "status": "open", ...}
curl -s -X POST http://localhost:8099/control/settle/<invoiceId>
# => {"ok": true, "status": "paid"}
curl -s "http://localhost:8099/api/invoice/<invoiceId>?wait=open"
# returns immediately once settled, with status "paid"
```
A GET with `wait=open` held while `open` is still current unblocks the
instant `/control/settle` (or `/control/expire`, `/control/partial`) fires,
which is what the page's own wait loop relies on — no polling on a timer.
Card invoices (`"method":"card"`) get a `clientSecret` in the response
instead of an address; BTC and XMR get `address` and `cryptoAmount`.
### The Stripe key
The publishable key lives in a `<meta id="stripe-publishable-key">` element
in `public/index.html`, committed empty. `mock/server.py` substitutes
`$STRIPE_PUBLISHABLE_KEY` into the served page and refuses to start if it is
set to anything but a `pk_`-prefixed key (a secret or restricted key would
otherwise be baked into a page anyone can read).
With no key set, the card path renders a labelled development stand-in
instead of a Stripe Payment Element: its button does what a successful
confirm does, and settling it calls the mock's `/control/settle` directly.
**This stand-in cannot appear when a key is set** — the code path that
builds it is unreachable once Stripe.js has actually loaded. To see the real
Payment Element, set a test key:
```
STRIPE_PUBLISHABLE_KEY=pk_test_... python3 mock/server.py --port 8099
```
## What is not verified here
The test suite runs in Node, so it asserts structure rather than rendering:
CSS is checked by parsing `styles.css`, `inert` by the attribute, the QR by
decoding the path the encoder produced, and the service worker by driving
`public/sw.js` in a Node `vm`.
The screens themselves **have** been rendered and reviewed, in both themes at
desktop and phone widths, using headless Chromium driven by Playwright
installed outside this package — the whole purchase was walked through,
settled against the mock, and photographed. Nothing in `package.json`
changed; do the same rather than trusting the suite for anything visual.
What still cannot be checked anywhere here:
- A real QR scan with a phone camera.
- Stripe.js loading, mounting a Payment Element and confirming a payment,
which need a browser **and** a Stripe account. The SDK method names come
from the spec and every test drives a fake, so a wrong name stays green here
and fails in production.
- A real service worker installing and serving the precache with the network
genuinely off.
- System fonts: headless Chromium substitutes DejaVu, so type metrics differ
from a real machine.
Before shipping a change that touches any of these, check it by hand in a
real browser: the wizard's two panels travel together for the length of a
step and neither one blinks out, panels ahead of the buyer are unreachable by
Tab, the menu closes on Escape and hands focus back to its button, both
themes are drawn from the menu's own control as well as from the operating
system, offline reload actually serves the cached build, and (with a test
key) the Payment Element mounts and confirms.
## What is not implemented
The web manifest is absent. The design calls it optional: offline support
needs the service worker, not the manifest.
The asset table is otherwise built. The brand mark is the difference worth
knowing about: `public/img/wordmark-*.svg` and `public/img/symbol-*.svg` are
the official files copied out of `website/` and `media-logos/`, served under
the build hash and drawn by the stylesheet — NOT path data transcribed into a
module. `design.test.ts` compares each one against its source byte for byte,
so a hand-edited mark fails the suite. The one edit is the dark wordmark,
which is the light file with its `#030749` lettering set to white, exactly as
simplex.chat's own dark header shows it.
## Content Security Policy
This repository has no CSP — it belongs on the listener that serves the app
in production, not in this app. Without one, in
particular without `script-src https://js.stripe.com`, card payments break
silently and nothing in this test suite would catch it. The policy the spec
specifies:
```
default-src 'self';
script-src 'self' https://js.stripe.com https://*.js.stripe.com;
frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com;
connect-src 'self' https://api.stripe.com;
img-src 'self' https://*.stripe.com;
frame-ancestors 'self' https://simplex.chat https://*.simplex.chat
```
`frame-ancestors` is what lets the site embed this app in an iframe while every other origin is
refused (clickjacking). Drop the `simplex.chat` entries to forbid embedding entirely.
## Embedding in the site
The app runs standalone or inside an iframe on simplex.chat. It detects the frame
(`window.self !== window.top`), points the wordmark at the site's own home on the top window, and
speaks a small theme protocol so one switch drives both (`src/embed.ts`):
- On load the frame posts `{ type: "simplex-embed-ready", theme }` to its parent.
- The host posts `{ type: "simplex-theme", theme }` (theme is `light` | `dark` | `system`) to drive
the frame; the frame applies it only from a trusted origin (`https://simplex.chat` or a subdomain).
- When the buyer uses the in-frame theme control, the frame echoes the same message back so the
host's own control stays in step.
The host page hides its own navbar and frames the app full-bleed:
```html
<iframe id="badges" src="https://badges.simplex.chat/"
style="width:100%;height:100vh;border:0;display:block"></iframe>
<script>
const frame = document.getElementById("badges");
const APP = "https://badges.simplex.chat";
// current site theme: "light" | "dark" | "system"
const send = (theme) => frame.contentWindow.postMessage({ type: "simplex-theme", theme }, APP);
window.addEventListener("message", (e) => {
if (e.origin !== APP) return;
if (e.data?.type === "simplex-embed-ready") send(currentSiteTheme());
if (e.data?.type === "simplex-theme") applySiteTheme(e.data.theme); // the in-frame toggle echoed back
});
// call send(theme) whenever the site's own theme control changes.
</script>
```
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
// The build hash is written back into public/index.html and public/sw.js, so a served shell can never name a build other than the one on disk.
import { createHash } from "node:crypto";
import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = fileURLToPath(new URL("./", import.meta.url));
export const paths = {
compiled: `${root}build/src`,
stylesheet: `${root}public/styles.css`,
images: `${root}public/img`,
fonts: `${root}public/fonts`,
indexHtml: `${root}public/index.html`,
worker: `${root}public/sw.js`,
site: `${root}dist`,
};
/** This is long enough to avoid a collision and short enough to read in a URL. */
export const HASH_CHARS = 16;
export const ASSET_PATTERN = new RegExp(`/assets/[0-9a-f]{${HASH_CHARS}}/`, "g");
export const BUILD_PATTERN = /const BUILD = "[0-9a-f]+";/;
/** The sourceMappingURL comment is removed because the maps are not copied and would 404 on every devtools open. */
export function served(source) {
return source.replace(/\n?\/\/# sourceMappingURL=.*\n?$/, "\n");
}
export function hashOf(files) {
const digest = createHash("sha256");
for (const [name, content] of [...files].sort((a, b) => a[0].localeCompare(b[0]))) {
digest.update(name);
digest.update("\0");
digest.update(content);
}
return digest.digest("hex").slice(0, HASH_CHARS);
}
const IMAGE_TYPES = [".png", ".svg"];
const FONT_TYPES = [".woff2"];
/** Images and fonts sit flat beside styles.css so that a url(hero-light.png) or url(Manrope-Regular.woff2) reference in the stylesheet resolves. */
/**
* This runs as a classic non-module script so that it executes before the first paint.
* The CSP allows script-src 'self' but not inline, so it must be served as a file rather than inlined.
*/
const INIT_JS = `(function () {
var r = document.documentElement;
try {
var raw = localStorage.getItem("sb.theme.v1");
var t = raw ? JSON.parse(raw) : "system";
if (t !== "light" && t !== "dark" && t !== "system") t = "system";
if (t === "system") r.removeAttribute("data-theme"); else r.setAttribute("data-theme", t);
r.style.colorScheme = (t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches)) ? "dark" : "light";
} catch (e) {}
try {
var h = location.hash;
var landing = (h === "" || h === "#" || h === "#/") && location.search.indexOf("order=") < 0;
if (!landing) r.classList.add("sb-booting");
} catch (e) {}
})();
`;
export function assets(compiled = paths.compiled, stylesheet = paths.stylesheet, images = paths.images, fonts = paths.fonts) {
const modules = readdirSync(compiled).filter((f) => f.endsWith(".js")).sort();
if (modules.length === 0) throw new Error("build: build/src holds no modules — run tsc first");
const pictures = readdirSync(images).filter((f) => IMAGE_TYPES.some((t) => f.endsWith(t))).sort();
const faces = readdirSync(fonts).filter((f) => FONT_TYPES.some((t) => f.endsWith(t))).sort();
return [
...modules.map((name) => [name, served(readFileSync(`${compiled}/${name}`, "utf8"))]),
["styles.css", readFileSync(stylesheet, "utf8")],
["init.js", INIT_JS],
...pictures.map((name) => [name, readFileSync(`${images}/${name}`)]),
...faces.map((name) => [name, readFileSync(`${fonts}/${name}`)]),
];
}
/** This throws rather than returning the text unchanged, because a silent no-op would ship a shell that still names the previous build. */
export function retarget(text, pattern, replacement, what) {
if (!new RegExp(pattern.source).test(text)) throw new Error(`build: no ${what} to rewrite`);
return text.replace(pattern, replacement);
}
export function withBuild(html, build) {
return retarget(html, ASSET_PATTERN, `/assets/${build}/`, "asset path");
}
export async function prerenderShell() {
const dom = await import("./build/test/stub-dom.js");
const prevDoc = Object.getOwnPropertyDescriptor(globalThis, "document");
const prevNav = Object.getOwnPropertyDescriptor(globalThis, "navigator");
dom.installDocument();
try {
const screens = await import("./build/src/screens.js");
const noop = () => {};
const chromeHtml = screens.chrome({
theme: "system", onNewPurchase: noop, onHistory: noop, onTheme: noop, onToggle: noop, onHome: noop,
}).node.serialize();
const landingHtml = screens.landing({ onStart: noop }).serialize();
return { chromeHtml, appHtml: `<div class="track"><div class="rail">${landingHtml}</div></div>` };
} finally {
if (prevDoc) Object.defineProperty(globalThis, "document", prevDoc); else delete globalThis.document;
if (prevNav) Object.defineProperty(globalThis, "navigator", prevNav); else delete globalThis.navigator;
}
}
export const SHELL_SLOTS = /** @type {const} */ ([
["chrome", /(<!--shell:chrome-->)[\s\S]*?(<!--\/shell:chrome-->)/],
["app", /(<!--shell:app-->)[\s\S]*?(<!--\/shell:app-->)/],
]);
/** The markers are kept so the next build can find the slots again. */
export function injectShell(html, shell) {
let out = html;
for (const [slot, pattern] of SHELL_SLOTS) {
const body = slot === "chrome" ? shell.chromeHtml : shell.appHtml;
out = retarget(out, pattern, (_m, open, close) => `${open}${body}${close}`, `${slot} shell slot`);
}
return out;
}
export function withBuildId(js, build) {
return retarget(js, BUILD_PATTERN, `const BUILD = "${build}";`, "BUILD constant");
}
function put(file, content) {
let before = null;
try { before = readFileSync(file, "utf8"); } catch { /* the file may not exist yet */ }
if (before === content) return false;
writeFileSync(file, content);
return true;
}
export async function assemble() {
const files = assets();
const build = hashOf(files);
const shell = await prerenderShell();
// The whole site directory is removed first so a stale build hash cannot remain beside the current one.
rmSync(paths.site, { recursive: true, force: true });
mkdirSync(`${paths.site}/assets/${build}`, { recursive: true });
for (const [name, content] of files) writeFileSync(`${paths.site}/assets/${build}/${name}`, content);
// The shell is injected before the build hash is rewritten, because the injected markup carries no asset URLs while the hash rewrite still finds the ones in the head.
const indexSource = withBuild(injectShell(readFileSync(paths.indexHtml, "utf8"), shell), build);
const moved = [
put(paths.indexHtml, indexSource),
put(paths.worker, withBuildId(readFileSync(paths.worker, "utf8"), build)),
].some(Boolean);
copyFileSync(paths.indexHtml, `${paths.site}/index.html`);
copyFileSync(paths.worker, `${paths.site}/sw.js`);
return { build, files: files.length, moved };
}
if (process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url) {
const { build, files, moved } = await assemble();
console.log(`build ${build}: ${files} files in dist/assets/${build}/, with index.html and sw.js`);
if (moved) console.log("build: public/index.html and public/sw.js now name this build — commit them");
}
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const testDir = 'build/test';
if (!fs.existsSync(testDir)) {
console.error(`Error: test directory not found: ${testDir}`);
process.exit(1);
}
const files = fs.readdirSync(testDir, { recursive: true });
const testFiles = files.filter(f => typeof f === 'string' && f.endsWith('.test.js'));
if (testFiles.length === 0) {
console.error('Error: No test files found in build/test/');
process.exit(1);
}
console.log(`Found ${testFiles.length} test file(s)`);
@@ -0,0 +1,323 @@
"""Stands in for the Haskell service AND for Stripe and BTCPay, so the whole
browser flow can be driven without any of them. A test fixture: no signatures,
no persistence, no money, and not a specification of the real service.
Standard library only. Threaded, because the wait endpoint holds a connection.
Environment:
MOCK_HOLD_SECONDS how long GET /api/invoice/:id?wait= holds (default 30)
STRIPE_PUBLISHABLE_KEY substituted into the
served index.html. Public by design, but still not
committed: unset, the page has NO card form and
renders the development stand-in instead, whose
button does what a successful confirm does and whose
settling is POST /control/settle/<invoiceId> below.
Set it to a `pk_test_...` key to drive the real
Stripe path; a secret key here is refused at start.
"""
import json, os, re, secrets, sys, threading
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs
ROOT = Path(__file__).resolve().parent.parent
HOLD_SECONDS = float(os.environ.get("MOCK_HOLD_SECONDS", "30"))
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "").strip()
KEY_META = re.compile(r'(<meta id="stripe-publishable-key"[^>]*content=")[^"]*(")')
CATALOG = {
"price_supporter": {"badgeType": "supporter", "monthPrice": 700},
"price_legend": {"badgeType": "legend", "monthPrice": 7000},
}
OFFERS = {
"offer_3m": {"months": 3, "free": 1},
"offer_12m": {"months": 12, "discount": 50},
"offer_3m_s": {"months": 3, "free": 1},
"offer_12m_s": {"months": 12, "discount": 50},
}
MIME = {".html": "text/html", ".css": "text/css", ".js": "text/javascript",
".svg": "image/svg+xml", ".json": "application/json", ".webmanifest": "application/manifest+json"}
# BTCPay's default speed policy asks for one confirmation.
REQUIRED_CONFIRMATIONS = 1
ADDRESSES = {"btc": "bc1qexampleaddress0k3jq2wvcgmqz", "xmr": "48HqK2XmVexampleAddress9fRtWc"}
LOCK = threading.Lock()
INVOICES = {}
HASHES = {}
EVENTS = {}
def now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def total(price_id, offer_id):
price = CATALOG.get(price_id)
if price is None:
return None
if not offer_id:
return {"months": 1, "amount": price["monthPrice"]}
offer = OFFERS.get(offer_id)
if offer is None:
return None
gross = price["monthPrice"] * offer["months"]
if "free" in offer:
amount = price["monthPrice"] * (offer["months"] - offer["free"])
else:
amount = (gross * (100 - offer["discount"])) // 100
return {"months": offer["months"], "amount": amount}
def payment_mark(inv):
"""The figure the page shows and the verdict it shows it under, which together decide
which screen it is on."""
return (inv.get("cryptoAmountPaid") or "", inv.get("paidInFull") is True)
def swap_event(invoice_id):
"""Called under LOCK; returns the event to fire once it is released. A fresh Event for
whoever parks next, so a waiter that read the pre-change status but calls wait() after we
fire this one still catches its own (already-set) event instead of racing a clear() on a
shared one and missing the wake."""
old = EVENTS.get(invoice_id)
EVENTS[invoice_id] = threading.Event()
return old
def public_view(inv):
"""What the browser may see. Note what is absent: no code, no code hash — the service
never has the code."""
view = {
"status": inv["status"], "badgeType": inv["badgeType"], "months": inv["months"],
"amount": inv["amount"], "currency": inv["currency"],
"expiresAt": inv["expiresAt"],
}
for k in ("amountPaid", "cryptoAmountPaid", "cryptoAmountDue", "settledAt"):
if inv.get(k) is not None:
view[k] = inv[k]
if inv.get("paidInFull") is not None:
view["paidInFull"] = inv["paidInFull"]
if inv["method"] == "card":
view["clientSecret"] = inv["clientSecret"]
else:
view["address"] = inv["address"]
view["cryptoAmount"] = inv["cryptoAmount"]
view["cryptoCurrency"] = inv["method"]
view["requiredConfirmations"] = REQUIRED_CONFIRMATIONS
return view
def with_publishable_key(html):
"""The publishable key is compiled into the page. Here it comes
from the environment, so nothing that could be a real key is ever written
back into public/index.html. Unset leaves the committed empty value, which
is what selects the development stand-in."""
if not STRIPE_PUBLISHABLE_KEY:
return html
text, found = KEY_META.subn(lambda m: m.group(1) + STRIPE_PUBLISHABLE_KEY + m.group(2), html.decode())
if found != 1:
raise RuntimeError("mock: the shell has no stripe-publishable-key meta element to fill")
return text.encode()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def _send(self, status, payload, ctype="application/json"):
body = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
try:
self.send_response(status)
self.send_header("content-type", ctype)
self.send_header("content-length", str(len(body)))
self.send_header("cache-control", "no-store")
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
# A client that drops during a long poll leaves the invoice untouched and the page
# reissues on its next load, so there is nothing to retry.
self.close_connection = True
def _read_json(self):
length = int(self.headers.get("content-length") or 0)
try:
return json.loads(self.rfile.read(length) or b"{}")
except Exception:
return None
def do_POST(self):
path = urlparse(self.path).path
if path.startswith("/control/"):
parts = path.strip("/").split("/")
if len(parts) != 3:
return self._send(400, {"error": "bad_request"})
_, action, invoice_id = parts
with LOCK:
inv = INVOICES.get(invoice_id)
if inv is None:
return self._send(404, {"error": "not_found"})
if action == "settle":
inv["status"] = "paid"
inv["amountPaid"] = inv["amount"]
inv["settledAt"] = now_iso()
inv["paidInFull"] = True
if inv["method"] != "card":
inv["cryptoAmountPaid"] = inv["cryptoAmount"]
inv["cryptoAmountDue"] = "0.000"
elif action == "expire":
inv["status"] = "expired"
elif action == "confirming":
inv["amountPaid"] = inv["amount"]
inv["paidInFull"] = True
if inv["method"] != "card":
inv["cryptoAmountPaid"] = inv["cryptoAmount"]
inv["cryptoAmountDue"] = "0.000"
elif action == "verdict":
# Monero reports the payment as paid in full while its figures are still zero.
inv["paidInFull"] = True
elif action == "partial":
inv["amountPaid"] = inv["amount"] // 2
inv["paidInFull"] = False
if inv["method"] != "card":
inv["cryptoAmountPaid"] = "0.734"
inv["cryptoAmountDue"] = "0.752"
else:
return self._send(400, {"error": "bad_request"})
status = inv["status"]
old_event = swap_event(invoice_id)
if old_event is not None:
old_event.set()
return self._send(200, {"ok": True, "status": status})
if path.startswith("/api/invoice/") and path.endswith("/cancel"):
invoice_id = path[len("/api/invoice/"):-len("/cancel")]
with LOCK:
inv = INVOICES.get(invoice_id)
if inv is None:
return self._send(404, {"error": "not_found"})
if inv["status"] != "open":
return self._send(409, {"error": "not_open"})
if payment_mark(inv) != ("", False) or inv.get("amountPaid"):
# Cancelling a funded invoice would strand what the buyer already sent.
return self._send(409, {"error": "funded"})
inv["status"] = "expired"
payload = {"invoiceId": invoice_id, **public_view(inv)}
old_event = swap_event(invoice_id)
if old_event is not None:
old_event.set()
return self._send(200, payload)
if path == "/api/invoice":
body = self._read_json()
if not body or not isinstance(body.get("codeHash"), str) or not body["codeHash"]:
return self._send(400, {"error": "bad_request"})
if body.get("method") not in ("card", "btc", "xmr"):
return self._send(400, {"error": "bad_request"})
with LOCK:
if body["codeHash"] in HASHES:
return self._send(409, {"error": "code_conflict"})
t = total(body.get("priceId"), body.get("offerId"))
if t is None:
return self._send(400, {"error": "catalog_changed"})
invoice_id = secrets.token_urlsafe(16)
is_card = body["method"] == "card"
inv = {
"invoiceId": invoice_id, "method": body["method"], "status": "open",
"badgeType": CATALOG[body["priceId"]]["badgeType"], "months": t["months"],
"amount": t["amount"], "currency": "usd",
"expiresAt": (datetime.now(timezone.utc) + timedelta(hours=1))
.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"clientSecret": f"cs_test_{secrets.token_hex(12)}" if is_card else None,
"address": None if is_card else ADDRESSES[body["method"]],
"cryptoAmount": None if is_card else "1.482",
}
INVOICES[invoice_id] = inv
HASHES[body["codeHash"]] = invoice_id
EVENTS[invoice_id] = threading.Event()
payload = {"invoiceId": invoice_id, **public_view(inv)}
return self._send(200, payload)
return self._send(405, {"error": "bad_request"})
def do_GET(self):
parsed = urlparse(self.path)
# An empty `seenPaid=` means the page rendered no figure, which differs from the parameter
# being absent, so blank values are kept.
path, query = parsed.path, parse_qs(parsed.query, keep_blank_values=True)
if path.startswith("/api/invoice/"):
invoice_id = path[len("/api/invoice/"):]
with LOCK:
inv = INVOICES.get(invoice_id)
if inv is None:
return self._send(404, {"error": "not_found"})
current = inv["status"]
held = payment_mark(inv)
event = EVENTS.get(invoice_id)
wait = (query.get("wait") or [None])[0]
# A payment recorded before this request arrived cannot fire the event this request
# would wait on, so holding then would strand the buyer on the payment screen.
seen = ((query.get("seenPaid") or [""])[0], (query.get("seenFull") or ["0"])[0] == "1")
unseen = "seenPaid" in query and seen != held
if wait is not None and wait == current and not unseen and event is not None:
# The event is never cleared; settle, expire and partial replace it with a fresh
# one under the lock, so a set event always means a change happened after this
# reference was taken.
event.wait(timeout=HOLD_SECONDS)
with LOCK:
inv = INVOICES[invoice_id]
payload = {"invoiceId": invoice_id, **public_view(inv)}
return self._send(200, payload)
rel = "index.html" if path == "/" else path.lstrip("/")
for base in ("public", "dist"):
base_resolved = (ROOT / base).resolve()
candidate = (base_resolved / rel).resolve()
try:
candidate.relative_to(base_resolved)
except ValueError:
# The path escaped this base directory, so move to the next one.
continue
if candidate.is_file():
ctype = MIME.get(candidate.suffix, "application/octet-stream")
body = candidate.read_bytes()
if candidate.name == "index.html":
body = with_publishable_key(body)
return self._send(200, body, ctype)
return self._send(404, {"error": "not_found"})
class Server(ThreadingHTTPServer):
daemon_threads = True
def handle_error(self, request, client_address):
# socketserver's default prints a traceback for a dropped client, which is routine under
# a long poll, so suppress it while letting anything else surface.
if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
return
super().handle_error(request, client_address)
def main():
port = 8099
if "--port" in sys.argv:
port = int(sys.argv[sys.argv.index("--port") + 1])
if STRIPE_PUBLISHABLE_KEY and not STRIPE_PUBLISHABLE_KEY.startswith("pk_"):
# A secret or restricted key (sk_, rk_) would be written into the page for anyone to read,
# so only a publishable key (pk_) is allowed.
sys.exit("mock: STRIPE_PUBLISHABLE_KEY must be a publishable key (pk_...)")
server = Server(("127.0.0.1", port), Handler)
print(f"mock badge service on http://localhost:{port}", flush=True)
print("stripe: " + (f"publishable key {STRIPE_PUBLISHABLE_KEY[:11]}… — the real card form"
if STRIPE_PUBLISHABLE_KEY
else "no STRIPE_PUBLISHABLE_KEY — the card path renders the development stand-in"),
flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
{
"name": "simplex-badge-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simplex-badge-web",
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^6.0.0"
}
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}
@@ -0,0 +1,14 @@
{
"name": "simplex-badge-web",
"private": true,
"type": "module",
"scripts": {
"build": "rm -rf build && tsc -p tsconfig.json && tsc -p tsconfig.test.json && node build.js",
"test": "rm -rf build && tsc -p tsconfig.json && tsc -p tsconfig.test.json && node check-tests.js && TZ=UTC node --test 'build/test/**/*.test.js'",
"mock": "python3 mock/server.py"
},
"devDependencies": {
"typescript": "^6.0.0",
"@types/node": "^24.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

@@ -0,0 +1,10 @@
<svg width="34" height="35" viewBox="0 0 34 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.02958 8.60922L8.622 14.2013L14.3705 8.45375L17.1669 11.2498L11.4183 16.9972L17.0114 22.5895L14.1373 25.4633L8.54422 19.871L2.79636 25.6187L0 22.8227L5.74794 17.075L0.155484 11.483L3.02958 8.60922Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0923 25.5156L16.944 22.6642L16.9429 22.6634L22.6467 16.9612L17.0513 11.3675L17.0523 11.367L14.2548 8.56979L8.65972 2.97535L11.5114 0.123963L17.1061 5.71849L22.8099 0.015625L25.6074 2.81285L19.9035 8.51562L25.4984 14.1099L31.2025 8.40729L34 11.2045L28.2958 16.907L33.8917 22.5017L31.0399 25.3531L25.4442 19.7584L19.7409 25.4611L25.3365 31.0559L22.4848 33.9073L16.8892 28.3124L11.1864 34.0156L8.38885 31.2184L14.0923 25.5156Z" fill="url(#paint0_linear_656_10815)"/>
<defs>
<linearGradient id="paint0_linear_656_10815" x1="12.8381" y1="-0.678252" x2="9.54355" y2="31.4493" gradientUnits="userSpaceOnUse">
<stop stop-color="#01F1FF"/>
<stop offset="1" stop-color="#0197FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,15 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_14_10)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.02972 8.59396L8.62219 14.186L14.3703 8.43848L17.1668 11.2346L11.4182 16.982L17.0112 22.5742L14.1371 25.448L8.5441 19.8557L2.79651 25.6035L0 22.8074L5.74813 17.0597L0.155656 11.4678L3.02972 8.59396Z" fill="#023789"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0922 25.5L16.9434 22.6486L16.9423 22.6478L22.6464 16.9456L17.0512 11.3519L17.0518 11.3514L14.2542 8.55418L8.65961 2.95973L11.5114 0.108337L17.106 5.70288L22.8095 0L25.607 2.79722L19.903 8.5L25.4981 14.0943L31.2022 8.39169L33.9997 11.1889L28.2957 16.8914L33.8914 22.4861L31.0396 25.3375L25.4439 19.7428L19.7404 25.4454L25.3361 31.0403L22.4843 33.8917L16.8887 28.2968L11.1862 34L8.38867 31.2028L14.0922 25.5Z" fill="url(#paint0_linear_14_10)"/>
</g>
<defs>
<linearGradient id="paint0_linear_14_10" x1="12.8379" y1="-0.693875" x2="9.54344" y2="31.4337" gradientUnits="userSpaceOnUse">
<stop stop-color="#01F1FF"/>
<stop offset="1" stop-color="#0197FF"/>
</linearGradient>
<clipPath id="clip0_14_10">
<rect width="34" height="34" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,11 @@
<svg width="161" height="40" viewBox="0 0 161 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.36956 9.95312C9.07658 9.95312 9.77404 10.0183 10.462 10.1487C11.1499 10.2791 11.8091 10.4467 12.4397 10.6516C13.0703 10.8565 13.6579 11.0894 14.2025 11.3502C14.7471 11.6109 15.2487 11.8717 15.7073 12.1325L13.6435 15.9324L13.6153 15.9014C13.5338 15.822 13.3426 15.6926 13.0416 15.5133C12.6977 15.3084 12.2677 15.0942 11.7518 14.8707C11.2359 14.6472 10.6721 14.4516 10.0607 14.2839C9.4492 14.1163 8.83772 14.0325 8.22625 14.0325C6.54469 14.0325 5.70391 14.582 5.70391 15.681C5.70391 16.0162 5.79468 16.2956 5.97621 16.5192C6.15774 16.7427 6.42526 16.9429 6.77877 17.1199C7.13228 17.2969 7.57656 17.4598 8.1116 17.6089C8.64664 17.7579 9.26767 17.9255 9.97469 18.1118C10.9492 18.3726 11.8282 18.6566 12.6117 18.964C13.3951 19.2713 14.0591 19.6532 14.6037 20.1095C15.1483 20.5659 15.5687 21.1201 15.8649 21.772C16.1611 22.424 16.3092 23.2063 16.3092 24.119C16.3092 25.2367 16.0942 26.182 15.6643 26.955C15.2343 27.728 14.6658 28.352 13.9588 28.827C13.2518 29.302 12.4397 29.6466 11.5225 29.8608C10.6053 30.075 9.65939 30.1821 8.68486 30.1821C7.93962 30.1821 7.17528 30.1263 6.39182 30.0145C5.60837 29.9027 4.84403 29.7398 4.09879 29.5255C3.35356 29.3113 2.63221 29.0552 1.93475 28.7572C1.23728 28.4591 0.592366 28.1145 0 27.7234L2.06373 23.7279L2.10045 23.7667C2.20495 23.8665 2.44112 24.0305 2.80896 24.2587C3.22935 24.5195 3.75006 24.7803 4.37109 25.0411C4.99212 25.3018 5.68481 25.5347 6.44915 25.7396C7.21349 25.9445 7.98739 26.0469 8.77084 26.0469C10.4333 26.0469 11.2645 25.5533 11.2645 24.5661C11.2645 24.1935 11.1403 23.8862 10.8919 23.644C10.6435 23.4019 10.2995 23.183 9.86003 22.9874C9.42054 22.7918 8.89983 22.6102 8.29791 22.4426C7.69599 22.2749 7.04152 22.0887 6.3345 21.8838C5.39818 21.6044 4.58606 21.3017 3.89815 20.9757C3.21024 20.6497 2.64176 20.2725 2.19271 19.8441C1.74366 19.4157 1.40926 18.9221 1.18951 18.3633C0.969761 17.8044 0.859887 17.1525 0.859887 16.4074C0.859887 15.3643 1.06053 14.4423 1.46181 13.6413C1.86309 12.8403 2.40768 12.1651 3.09559 11.6156C3.7835 11.0661 4.58129 10.6516 5.48894 10.3722C6.3966 10.0928 7.35681 9.95312 8.36956 9.95312ZM27.1234 10.1208V29.9586H22.4226V10.1208H27.1234ZM39.4853 10.1208L44.7593 20.4867L50.0619 10.1208H55.1353V29.9586H50.4345V18.0839L46.0205 26.7175H43.4981L39.084 18.0839V29.9586H34.3833V10.1208H39.4853ZM86.6338 10.1208V25.9352H96.4939V29.9586H81.9331V10.1208H86.6338ZM115.547 10.1208V14.1442H106.203V18H114.229V21.7161H106.203V25.9352H115.805V29.9586H101.502V10.1208H115.547ZM70.5198 10.1208L70.8416 10.128C71.6918 10.1666 72.4779 10.3598 73.1998 10.7075C74.0119 11.0987 74.7093 11.6109 75.2922 12.2443C75.875 12.8776 76.3336 13.5947 76.668 14.3957C77.0024 15.1967 77.1696 16.0069 77.1696 16.8265C77.1696 17.702 77.0119 18.5402 76.6966 19.3412C76.3813 20.1421 75.9419 20.8593 75.3781 21.4926C74.8144 22.1259 74.1313 22.6289 73.3287 23.0014C72.6154 23.3326 71.834 23.5165 70.9848 23.5533L70.6631 23.5602H66.5356V29.9586H61.8349V10.1208H70.5198ZM70.2045 14.1442H66.5356V19.5647H70.3765C70.9306 19.5647 71.4083 19.3319 71.8096 18.8662C72.2109 18.4005 72.4115 17.7206 72.4115 16.8265C72.4115 16.3608 72.3494 15.9604 72.2252 15.6251C72.101 15.2898 71.9338 15.0104 71.7236 14.7869C71.5134 14.5633 71.2746 14.4003 71.0071 14.2979C70.7395 14.1954 70.472 14.1442 70.2045 14.1442Z" fill="#FFFFFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M124.336 10.1101L130.957 16.689L137.762 9.92721L141.072 13.2167L134.267 19.9784L140.888 26.5575L137.486 29.9384L130.865 23.3592L124.06 30.1213L120.75 26.8318L127.554 20.0699L120.934 13.491L124.336 10.1101Z" fill="#FFFFFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M137.433 30L140.809 26.6454L140.807 26.6445L147.56 19.936L140.936 13.3552L140.937 13.3546L137.625 10.0637L131.002 3.48203L134.377 0.127456L141.001 6.70925L147.753 0L151.065 3.29085L144.312 10L150.936 16.5815L157.688 9.87254L161 13.1634L154.247 19.8722L160.872 26.4542L157.496 29.8088L150.871 23.2268L144.12 29.9358L150.744 36.518L147.368 39.8725L140.744 33.2904L133.993 40L130.681 36.7092L137.433 30Z" fill="url(#paint0_linear_40_164)"/>
<defs>
<linearGradient id="paint0_linear_40_164" x1="135.948" y1="-0.816326" x2="132.096" y2="36.9857" gradientUnits="userSpaceOnUse">
<stop stop-color="#01F1FF"/>
<stop offset="1" stop-color="#0197FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,11 @@
<svg width="161" height="40" viewBox="0 0 161 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.36956 9.95312C9.07658 9.95312 9.77404 10.0183 10.462 10.1487C11.1499 10.2791 11.8091 10.4467 12.4397 10.6516C13.0703 10.8565 13.6579 11.0894 14.2025 11.3502C14.7471 11.6109 15.2487 11.8717 15.7073 12.1325L13.6435 15.9324L13.6153 15.9014C13.5338 15.822 13.3426 15.6926 13.0416 15.5133C12.6977 15.3084 12.2677 15.0942 11.7518 14.8707C11.2359 14.6472 10.6721 14.4516 10.0607 14.2839C9.4492 14.1163 8.83772 14.0325 8.22625 14.0325C6.54469 14.0325 5.70391 14.582 5.70391 15.681C5.70391 16.0162 5.79468 16.2956 5.97621 16.5192C6.15774 16.7427 6.42526 16.9429 6.77877 17.1199C7.13228 17.2969 7.57656 17.4598 8.1116 17.6089C8.64664 17.7579 9.26767 17.9255 9.97469 18.1118C10.9492 18.3726 11.8282 18.6566 12.6117 18.964C13.3951 19.2713 14.0591 19.6532 14.6037 20.1095C15.1483 20.5659 15.5687 21.1201 15.8649 21.772C16.1611 22.424 16.3092 23.2063 16.3092 24.119C16.3092 25.2367 16.0942 26.182 15.6643 26.955C15.2343 27.728 14.6658 28.352 13.9588 28.827C13.2518 29.302 12.4397 29.6466 11.5225 29.8608C10.6053 30.075 9.65939 30.1821 8.68486 30.1821C7.93962 30.1821 7.17528 30.1263 6.39182 30.0145C5.60837 29.9027 4.84403 29.7398 4.09879 29.5255C3.35356 29.3113 2.63221 29.0552 1.93475 28.7572C1.23728 28.4591 0.592366 28.1145 0 27.7234L2.06373 23.7279L2.10045 23.7667C2.20495 23.8665 2.44112 24.0305 2.80896 24.2587C3.22935 24.5195 3.75006 24.7803 4.37109 25.0411C4.99212 25.3018 5.68481 25.5347 6.44915 25.7396C7.21349 25.9445 7.98739 26.0469 8.77084 26.0469C10.4333 26.0469 11.2645 25.5533 11.2645 24.5661C11.2645 24.1935 11.1403 23.8862 10.8919 23.644C10.6435 23.4019 10.2995 23.183 9.86003 22.9874C9.42054 22.7918 8.89983 22.6102 8.29791 22.4426C7.69599 22.2749 7.04152 22.0887 6.3345 21.8838C5.39818 21.6044 4.58606 21.3017 3.89815 20.9757C3.21024 20.6497 2.64176 20.2725 2.19271 19.8441C1.74366 19.4157 1.40926 18.9221 1.18951 18.3633C0.969761 17.8044 0.859887 17.1525 0.859887 16.4074C0.859887 15.3643 1.06053 14.4423 1.46181 13.6413C1.86309 12.8403 2.40768 12.1651 3.09559 11.6156C3.7835 11.0661 4.58129 10.6516 5.48894 10.3722C6.3966 10.0928 7.35681 9.95312 8.36956 9.95312ZM27.1234 10.1208V29.9586H22.4226V10.1208H27.1234ZM39.4853 10.1208L44.7593 20.4867L50.0619 10.1208H55.1353V29.9586H50.4345V18.0839L46.0205 26.7175H43.4981L39.084 18.0839V29.9586H34.3833V10.1208H39.4853ZM86.6338 10.1208V25.9352H96.4939V29.9586H81.9331V10.1208H86.6338ZM115.547 10.1208V14.1442H106.203V18H114.229V21.7161H106.203V25.9352H115.805V29.9586H101.502V10.1208H115.547ZM70.5198 10.1208L70.8416 10.128C71.6918 10.1666 72.4779 10.3598 73.1998 10.7075C74.0119 11.0987 74.7093 11.6109 75.2922 12.2443C75.875 12.8776 76.3336 13.5947 76.668 14.3957C77.0024 15.1967 77.1696 16.0069 77.1696 16.8265C77.1696 17.702 77.0119 18.5402 76.6966 19.3412C76.3813 20.1421 75.9419 20.8593 75.3781 21.4926C74.8144 22.1259 74.1313 22.6289 73.3287 23.0014C72.6154 23.3326 71.834 23.5165 70.9848 23.5533L70.6631 23.5602H66.5356V29.9586H61.8349V10.1208H70.5198ZM70.2045 14.1442H66.5356V19.5647H70.3765C70.9306 19.5647 71.4083 19.3319 71.8096 18.8662C72.2109 18.4005 72.4115 17.7206 72.4115 16.8265C72.4115 16.3608 72.3494 15.9604 72.2252 15.6251C72.101 15.2898 71.9338 15.0104 71.7236 14.7869C71.5134 14.5633 71.2746 14.4003 71.0071 14.2979C70.7395 14.1954 70.472 14.1442 70.2045 14.1442Z" fill="#030749"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M124.336 10.1101L130.957 16.689L137.762 9.92721L141.072 13.2167L134.267 19.9784L140.888 26.5575L137.486 29.9384L130.865 23.3592L124.06 30.1213L120.75 26.8318L127.554 20.0699L120.934 13.491L124.336 10.1101Z" fill="#030749"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M137.433 30L140.809 26.6454L140.807 26.6445L147.56 19.936L140.936 13.3552L140.937 13.3546L137.625 10.0637L131.002 3.48203L134.377 0.127456L141.001 6.70925L147.753 0L151.065 3.29085L144.312 10L150.936 16.5815L157.688 9.87254L161 13.1634L154.247 19.8722L160.872 26.4542L157.496 29.8088L150.871 23.2268L144.12 29.9358L150.744 36.518L147.368 39.8725L140.744 33.2904L133.993 40L130.681 36.7092L137.433 30Z" fill="url(#paint0_linear_40_164)"/>
<defs>
<linearGradient id="paint0_linear_40_164" x1="135.948" y1="-0.816326" x2="132.096" y2="36.9857" gradientUnits="userSpaceOnUse">
<stop stop-color="#01F1FF"/>
<stop offset="1" stop-color="#0197FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<!-- Let the browser paint its dark canvas on a reload's blank frame, so a dark-theme buyer is not
flashed white before the stylesheet lands. The stylesheet declares `color-scheme` per theme; this
is the earliest hint, read before the stylesheet parses. -->
<meta name="color-scheme" content="light dark">
<!-- the `publishable_key`: public by design, and empty here. The deployment
substitutes its own when it serves this file; `mock/server.py` does it from
$STRIPE_PUBLISHABLE_KEY. Empty means no card form and a development
stand-in (`src/stripe.ts`). A real key is never committed. -->
<meta id="stripe-publishable-key" name="stripe-publishable-key" content="">
<!-- The two page grounds, so the browser paints the right one before the
stylesheet lands rather than flashing white into the dark theme. -->
<meta name="theme-color" content="#F3FAFF" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#000832" media="(prefers-color-scheme: dark)">
<title>Support SimpleX</title>
<!-- The official symbol, both variants, served from the build hash like every
other asset so the worker precaches it. -->
<link rel="icon" type="image/svg+xml" href="/assets/9539a668abb6a8c8/symbol-light.svg" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/svg+xml" href="/assets/9539a668abb6a8c8/symbol-dark.svg" media="(prefers-color-scheme: dark)">
<!-- The stylesheet before the script, not after: a classic script waits for a preceding stylesheet
to load before it runs, which holds the parser here until the CSS is in. Firefox otherwise paints
the parsed body unstyled while the stylesheet is still in flight (Chrome does not); blocking the
parser at this point keeps that unstyled frame off the screen. -->
<link rel="stylesheet" href="/assets/9539a668abb6a8c8/styles.css">
<!-- The pre-paint bootstrap: sets the saved theme and marks a non-landing reload before the first
paint. Classic and blocking, served under the build hash (see `assets` in build.js) so it is
precached and cannot skew. Its wait on the stylesheet above still finishes before the first paint. -->
<script src="/assets/9539a668abb6a8c8/init.js"></script>
</head>
<body>
<!-- The app shell. `build.js` fills these two slots at build time with the serialized output of
chrome() and landing() from screens.ts (see `prerenderShell`), so the first paint is the real
first screen, not an empty page — no reload flashes blank — and the markup can never drift from
what `main.ts` builds and swaps in, because it IS that output. The markers stay; only the bytes
between them are rewritten. Embedded, the site owns the navbar, so `.embedded #chrome` hides
this copy just as the built one is not rendered there. -->
<div id="chrome"><!--shell:chrome--><header class="chrome-bar"><a class="brand" href="/" aria-label="SimpleX"></a><nav class="menu-wrap"><button class="menu-button" type="button" aria-label="Menu" aria-expanded="false" aria-controls="menu"><svg xmlns="http://www.w3.org/2000/svg" class="bars" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><line x1="3" y1="7" x2="21" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line><line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line><line x1="3" y1="17" x2="21" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line></svg></button><div class="menu" id="menu" hidden="" role="dialog" aria-label="Menu"><div class="menu-section"><div class="menu-row"><span class="menu-label">Theme</span><div class="segmented" role="group" aria-label="Theme"><button class="segment" type="button" aria-pressed="false">Light</button><button class="segment" type="button" aria-pressed="false">Dark</button><button class="segment" type="button" aria-pressed="true">System</button></div></div></div><div class="menu-section"><button class="menu-item" type="button">Buy a code</button><button class="menu-item" type="button">Your codes</button></div></div></nav></header><!--/shell:chrome--></div>
<main id="app" aria-live="polite"><!--shell:app--><div class="track"><div class="rail"><section class="panel"><h1>Support SimpleX</h1><p class="lede"><span class="line">Get a badge to send larger files (2‑5GB)</span> <span class="line">that stay available longer (7‑21 days),</span> <span class="line">and to show it on your profile.</span></p><div class="hero" role="presentation"></div><div class="notes"><p class="muted"><span class="half">You pay once for the months you choose.</span> <span class="half">No subscription, no account.</span></p></div><button class="primary" type="button">Choose your badge</button><div class="invest"><p><span class="half">Invest $100+ in SimpleX Chat</span> <span class="half">and get a free supporter badge.</span></p><a class="invest-link" href="https://wefunder.com/simplex.chat?utm_source=badges" target="_blank" rel="noopener" aria-label="Learn more on Wefunder">Learn more on <svg xmlns="http://www.w3.org/2000/svg" class="wefunder" viewBox="0 0 165 45" aria-hidden="true" focusable="false"><path fill="currentColor" d="M44.0386 38.5332H56.7241V35.3848H47.5317V31.6849H55.5291V28.5365H47.5317V24.9515H56.6092V21.8031H44.0386V38.5332ZM60.242 38.5332H63.7581V31.9836H71.8474V28.7663H63.7581V25.0204H72.9045V21.8031H60.242V38.5332ZM83.049 38.786C87.5302 38.786 90.3569 36.3041 90.3569 31.2482V21.8031H86.8408V31.4091C86.8408 34.1438 85.416 35.5457 83.0949 35.5457C80.7738 35.5457 79.372 34.0749 79.372 31.2712V21.8031H75.8329V31.3861C75.8329 36.2811 78.5906 38.786 83.049 38.786ZM94.3543 38.5332H97.8245V27.6402L106.144 38.5332H109.154V21.8031H105.661V32.3513L97.5946 21.8031H94.3543V38.5332ZM113.43 38.5332H119.888C125.151 38.5332 128.782 34.8792 128.782 30.1681V30.1222C128.782 25.4111 125.151 21.8031 119.888 21.8031H113.43V38.5332ZM119.888 24.9974C122.99 24.9974 125.082 27.1346 125.082 30.1681V30.2141C125.082 33.2476 122.99 35.3388 119.888 35.3388H116.946V24.9974H119.888ZM132.304 38.5332H144.99V35.3848H135.797V31.6849H143.795V28.5365H135.797V24.9515H144.875V21.8031H132.304V38.5332ZM152.024 29.9613V24.9974H155.816C157.677 24.9974 158.826 25.8477 158.826 27.4564V27.5023C158.826 28.9731 157.723 29.9613 155.885 29.9613H152.024ZM148.508 38.5332H152.024V33.0867H155.08L158.78 38.5332H162.917L158.734 32.4892C160.894 31.7308 162.388 30.0302 162.388 27.3185V27.2725C162.388 25.7328 161.906 24.4229 160.986 23.5037C159.883 22.4006 158.229 21.8031 156.114 21.8031H148.508V38.5332Z"></path><path fill="none" stroke="currentColor" stroke-width="4.86264" d="M3 29.4853L6.36524 22.194C6.60628 21.6717 7.35366 21.6867 7.57359 22.2182L14.3487 38.5913C14.5779 39.1453 15.3677 39.1308 15.5764 38.5687L23.1127 18.279C23.315 17.7341 24.0721 17.6984 24.3249 18.2217L32.5064 35.1596C32.7713 35.7081 33.5744 35.6351 33.7362 35.048L43.1168 1"></path></svg></a></div></section></div></div><!--/shell:app--></main>
<!-- Named, so `main.ts` can take it out of the accessibility tree with the
screen while the menu is open. -->
<footer id="contact"><a href="https://simplex.chat/contact">simplex.chat/contact</a></footer>
<script type="module" src="/assets/9539a668abb6a8c8/main.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+155
View File
@@ -0,0 +1,155 @@
// A classic worker script, so its export is the worker's own global: `self`. `test/sw.test.ts`
// runs this exact file in a Node `vm` with a fake Cache API and reads `self.sw`.
/** Rewritten by `build.js` from the bytes of the compiled modules, the stylesheet and the images. */
const BUILD = "9539a668abb6a8c8";
const ASSETS = `/assets/${BUILD}/`;
/** One cache per build, so eviction is "everything that is not this one". */
const CACHE = `sb-${BUILD}`;
const SHELL = "/";
// past this the cached shell is served instead; an answer that arrives later is dropped
const SHELL_TIMEOUT_MS = 3000;
/** Proves a document really is this build's shell and not an interstitial. */
const ENTRY = `${ASSETS}main.js`;
// Explicit URLs under one build hash, so a shell and the modules it imports can never skew.
// `/` is not here: it is fetched and checked separately, being the one response an
// interstitial can substitute. The images and fonts are precached because the stylesheet asks
// for them by relative URL, which puts them under this hash too.
const PRECACHE = [
`${ASSETS}styles.css`,
`${ASSETS}init.js`,
`${ASSETS}hero-dark.png`,
`${ASSETS}hero-light.png`,
`${ASSETS}wordmark-dark.svg`,
`${ASSETS}wordmark-light.svg`,
`${ASSETS}symbol-dark.svg`,
`${ASSETS}symbol-light.svg`,
`${ASSETS}GT-Walsheim-LC-Bold.woff2`,
`${ASSETS}Manrope-Regular.woff2`,
`${ASSETS}Manrope-Medium.woff2`,
`${ASSETS}Manrope-SemiBold.woff2`,
`${ASSETS}Manrope-Bold.woff2`,
`${ASSETS}api.js`,
`${ASSETS}order.js`,
`${ASSETS}format.js`,
`${ASSETS}domain.js`,
`${ASSETS}embed.js`,
`${ASSETS}catalog.js`,
`${ASSETS}codes.js`,
`${ASSETS}crowdfunding.js`,
`${ASSETS}flow.js`,
`${ASSETS}icons.js`,
`${ASSETS}main.js`,
`${ASSETS}parse.js`,
`${ASSETS}qr.js`,
`${ASSETS}routing.js`,
`${ASSETS}screens.js`,
`${ASSETS}store.js`,
// our card module, not Stripe's script, which is another origin and never cached
`${ASSETS}stripe.js`,
];
/** The whole routing decision, as a pure function of the URL. */
function strategyFor(url) {
if (url.origin !== self.location.origin) return "bypass";
// Before anything that could reach a cache. A cached `paid` would show a code for an order
// that later failed, and a cached `open` would hide a settled one; `Cache-Control: no-store`
// covers the HTTP cache, and this line covers the Cache API.
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) return "network-only";
if (url.pathname === SHELL || url.pathname === "/index.html") return "shell";
if (url.pathname.startsWith(ASSETS)) return "asset";
return "bypass";
}
/** Leaves the cache either complete or absent: a half-filled one serves a shell whose modules
* 404 offline, for as long as that build lives. */
async function precache() {
// This file is not part of its own hash, so a change to it alone re-installs against the
// same cache name and `caches.open` hands back the one the active worker is serving from.
// What this install created is all it may destroy.
const existed = (await caches.keys()).includes(CACHE);
const cache = await caches.open(CACHE);
try {
// Anubis serves its challenge as HTML at the page's own path, so the shell is verified
// before it is stored. `reload` so the answer is not one the HTTP cache had lying about.
const shell = await fetch(SHELL, { cache: "reload", credentials: "same-origin" });
if (!shell.ok) throw new Error(`sw: the shell answered ${shell.status}`);
const html = await shell.clone().text();
if (!html.includes(ENTRY)) throw new Error("sw: refusing to cache a document that is not this build's shell");
await cache.put(SHELL, shell);
await cache.addAll(PRECACHE);
} catch (e) {
// A failed re-install must leave the shipped build alone: offline support is what a buyer
// on a train has left, and an update check meeting a 5xx is no reason to take it away.
if (!existed) await caches.delete(CACHE);
throw e;
}
}
self.addEventListener("install", (event) => {
// After the precache, never before: a worker that took over with a half-filled cache would
// serve a shell whose modules are not there. Without `skipWaiting` at all, a new build only
// activated once every tab holding the old worker had closed, and a reload does not release
// one, so plain reloads kept getting the old build until a hard reload.
//
// `clients.claim()` stays out: it would swap the worker under a page whose modules are
// already loaded, which is the skew this whole file exists to prevent.
event.waitUntil(precache().then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
// Safe even with an old page still open: every asset URL carries its build hash, so a
// page whose cache has gone falls through to the network rather than to another build's file.
event.waitUntil(caches.keys().then((names) => Promise.all(
names.filter((name) => name !== CACHE).map((name) => caches.delete(name)),
)));
});
/** Cache-first, safe because an asset URL carries the build hash, so an entry can never be
* another build's file. Nothing is written here: this worker caches one build, once. */
async function fromPrecache(request) {
const cache = await caches.open(CACHE);
const hit = await cache.match(request);
return hit !== undefined && hit !== null ? hit : fetch(request);
}
/** Network-first, and the one response that must be: the shell is the only file whose URL
* carries no build hash, so it decides which build a page runs. Served cache-first, a redeploy
* could not reach a returning buyer at all, which once shipped a checkout generating codes
* the service could not redeem. */
async function shellFirst(request) {
let deadline;
try {
// SHELL rather than the request: every page URL is the same document, so one fetch serves
// them all and it is the key the fallback is stored under. `reload` because the HTTP cache
// can pin a build just as the Cache API can.
const network = fetch(SHELL, { cache: "reload", credentials: "same-origin" });
// a captive portal or a tunnel accepts the connection and never answers, and `fetch` does
// not reject on that: without a deadline the page is blank for the browser's own timeout
const stall = new Promise((resolve) => { deadline = setTimeout(() => { resolve(undefined); }, SHELL_TIMEOUT_MS); });
const fresh = await Promise.race([network, stall]);
if (fresh !== undefined && fresh.ok) return fresh;
} catch {
// offline, or the listener is down: the precached shell is what is left
} finally {
// the offline path throws past a clear that followed the await, and left the worker awake
clearTimeout(deadline);
}
const cache = await caches.open(CACHE);
const hit = await cache.match(SHELL);
return hit !== undefined && hit !== null ? hit : fetch(request);
}
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const strategy = strategyFor(new URL(request.url));
// Not responding is what keeps `/api/*` out of the Cache API: a catch-all handler would
// defeat `Cache-Control: no-store` on the way past.
if (strategy === "network-only" || strategy === "bypass") return;
// `?order=<id>` and `#/codes` are the same document, so the query string is not part of the key.
event.respondWith(strategy === "shell" ? shellFirst(request) : fromPrecache(request));
});
self.sw = { BUILD, CACHE, ASSETS, SHELL, ENTRY, PRECACHE, strategyFor };
+297
View File
@@ -0,0 +1,297 @@
import { readChain, readStatus, type Chain, type Method, type OrderStatus } from "./domain.js";
import { asObject, fieldsInto, filledText, flag, oneOf, positiveInteger, text, nonNegativeInteger } from "./parse.js";
const WIRE_ERROR_CODES = [
"catalog_changed", "bad_request", "code_conflict",
"rate_limited", "internal", "provider_unavailable", "not_found",
"not_open", "funded", "method_not_allowed",
] as const;
const LOCAL_ERROR_CODES = ["invalid_response", "unknown"] as const;
export type ErrorCode = (typeof WIRE_ERROR_CODES)[number] | (typeof LOCAL_ERROR_CODES)[number];
export class ApiError extends Error {
constructor(readonly code: ErrorCode, readonly status: number, readonly retryAfter?: number) {
super(`${code} (${status})`);
this.name = "ApiError";
}
}
export class AbortedError extends Error {
constructor() {
super("the wait was aborted");
this.name = "AbortedError";
}
}
const RETRY_AFTER_MAX_SECONDS = 300;
// The five-minute cap keeps a hostile Retry-After from parking the page for hours.
function retryAfterSeconds(res: Response): number | undefined {
const raw = res.headers.get("retry-after")?.trim();
if (!raw || !/^\d+$/.test(raw)) return undefined;
const seconds = Number(raw);
return seconds > 0 ? Math.min(seconds, RETRY_AFTER_MAX_SECONDS) : undefined;
}
async function drain(res: Response): Promise<void> {
try {
await res.text();
} catch { /* nothing left to reclaim */ }
}
export interface CreateRequest {
priceId: string;
offerId?: string;
method: Method;
codeHash: string;
}
export interface CreatedInvoice {
invoiceId: string;
badgeType: string;
months: number;
amount: number;
currency: string;
expiresAt: string;
clientSecret?: string;
address?: string;
cryptoAmount?: string;
cryptoCurrency?: Chain;
}
export interface InvoiceView {
status: OrderStatus;
amountPaid?: number;
cryptoAmountPaid?: string;
cryptoAmountDue?: string;
settledAt?: string;
badgeType?: string;
months?: number;
amount?: number;
currency?: string;
expiresAt?: string;
clientSecret?: string;
address?: string;
cryptoAmount?: string;
cryptoCurrency?: Chain;
paidInFull?: boolean;
requiredConfirmations?: number;
}
export function inferMethod(view: Pick<InvoiceView, "clientSecret" | "cryptoCurrency">): Method | undefined {
if (view.clientSecret !== undefined) return "card";
if (view.cryptoCurrency !== undefined) return view.cryptoCurrency;
return undefined;
}
// A proxy or CDN in front of the service can return its own 404 body, so the status alone decides not_found.
async function decodeError(res: Response): Promise<never> {
let code: ErrorCode = "unknown";
try {
code = wireError(asObject(await res.json())?.error) ?? "unknown";
} catch { /* an empty or unparseable body stays "unknown" */ }
if (res.status === 404) code = "not_found";
throw new ApiError(code, res.status, retryAfterSeconds(res));
}
const wireError = oneOf(WIRE_ERROR_CODES);
function matchesRequestedMethod(
method: Method,
clientSecret: string | undefined,
address: string | undefined,
cryptoAmount: string | undefined,
cryptoCurrency: Chain | undefined,
): boolean {
if (method === "card") {
return clientSecret !== undefined && address === undefined && cryptoAmount === undefined && cryptoCurrency === undefined;
}
return clientSecret === undefined && address !== undefined && cryptoAmount !== undefined && cryptoCurrency === method;
}
function parseCreatedInvoice(body: unknown, status: number, requestedMethod: Method): CreatedInvoice {
const b = asObject(body);
const refuse = (): never => { throw new ApiError("invalid_response", status); };
if (b === undefined) return refuse();
const invoiceId = filledText(b.invoiceId);
const badgeType = filledText(b.badgeType);
const months = positiveInteger(b.months);
const amount = positiveInteger(b.amount);
const currency = filledText(b.currency);
const expiresAt = filledText(b.expiresAt);
if (invoiceId === undefined || badgeType === undefined ||
months === undefined || amount === undefined || currency === undefined || expiresAt === undefined) {
return refuse();
}
const optional: Partial<CreatedInvoice> = {};
const take = fieldsInto<CreatedInvoice>(b, optional);
const read =
take("clientSecret", text) && take("address", text) &&
take("cryptoAmount", text) && take("cryptoCurrency", readChain);
if (!read) return refuse();
const { clientSecret, address, cryptoAmount, cryptoCurrency } = optional;
if (!matchesRequestedMethod(requestedMethod, clientSecret, address, cryptoAmount, cryptoCurrency)) return refuse();
return { invoiceId, badgeType, months, amount, currency, expiresAt, ...optional };
}
export function parseInvoiceView(body: unknown): InvoiceView | null {
const b = asObject(body);
if (b === undefined) return null;
const status = readStatus(b.status);
if (status === undefined) return null;
const rest: Partial<InvoiceView> = {};
const take = fieldsInto<InvoiceView>(b, rest);
const read =
take("amountPaid", nonNegativeInteger) && take("cryptoAmountPaid", text) && take("cryptoAmountDue", text) && take("settledAt", text) &&
take("badgeType", text) && take("months", positiveInteger) && take("amount", positiveInteger) &&
take("currency", text) && take("expiresAt", text) &&
take("clientSecret", text) && take("address", text) && take("cryptoAmount", text) &&
take("cryptoCurrency", readChain) && take("paidInFull", flag) &&
take("requiredConfirmations", nonNegativeInteger);
return read ? { status, ...rest } : null;
}
export async function createInvoice(req: CreateRequest, f: typeof fetch = fetch): Promise<CreatedInvoice> {
const body: Record<string, unknown> = { priceId: req.priceId, method: req.method, codeHash: req.codeHash };
if (req.offerId !== undefined) body.offerId = req.offerId;
const res = await f("/api/invoice", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) return decodeError(res);
let raw: unknown;
try {
raw = await res.json();
} catch {
throw new ApiError("invalid_response", res.status);
}
return parseCreatedInvoice(raw, res.status, req.method);
}
async function readView(res: Response): Promise<InvoiceView> {
if (!res.ok) return decodeError(res);
let raw: unknown;
try {
raw = await res.json();
} catch {
throw new ApiError("invalid_response", res.status);
}
const view = parseInvoiceView(raw);
if (view === null) throw new ApiError("invalid_response", res.status);
return view;
}
export async function cancelInvoice(orderId: string, f: typeof fetch = fetch): Promise<InvoiceView> {
return readView(await f(`/api/invoice/${encodeURIComponent(orderId)}/cancel`, { method: "POST" }));
}
export async function readInvoice(invoiceId: string, f: typeof fetch = fetch, signal?: AbortSignal): Promise<InvoiceView> {
return readView(await f(`/api/invoice/${encodeURIComponent(invoiceId)}`, signal ? { signal } : undefined));
}
export type Sleep = (ms: number, signal?: AbortSignal) => Promise<void>;
export const realSleep: Sleep = (ms, signal) => {
if (signal?.aborted) return Promise.reject(new AbortedError());
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new AbortedError());
};
signal?.addEventListener("abort", onAbort, { once: true });
});
};
export const BACKOFF_START = 1000;
export const BACKOFF_MAX = 30_000;
const FAST_THRESHOLD_MS = 5000;
const FAST_LIMIT = 3;
export interface PaymentMark {
paid: string | undefined;
paidInFull: boolean | undefined;
}
export const NOTHING_RECEIVED: PaymentMark = { paid: undefined, paidInFull: undefined };
export function paymentMark(view: InvoiceView): PaymentMark {
return { paid: view.cryptoAmountPaid, paidInFull: view.paidInFull };
}
function changed(now: PaymentMark, seen: PaymentMark): boolean {
return (now.paid ?? "") !== (seen.paid ?? "") || (now.paidInFull === true) !== (seen.paidInFull === true);
}
export async function waitForChange(
invoiceId: string,
seen: OrderStatus,
f: typeof fetch = fetch,
sleep: Sleep = realSleep,
signal?: AbortSignal,
now: () => number = Date.now,
seenPayment: PaymentMark = NOTHING_RECEIVED,
): Promise<InvoiceView> {
if (seen === "paid") {
throw new Error("waitForChange: 'paid' is terminal; there is nothing left to wait for");
}
let backoff = BACKOFF_START;
let fastRepeats = 0;
const backOff = async (): Promise<void> => {
await sleep(backoff, signal);
backoff = Math.min(backoff * 2, BACKOFF_MAX);
};
for (;;) {
if (signal?.aborted) throw new AbortedError();
const startedAt = now();
let res: Response;
try {
const query = `wait=${encodeURIComponent(seen)}`
+ `&seenPaid=${encodeURIComponent(seenPayment.paid ?? "")}`
+ `&seenFull=${seenPayment.paidInFull === true ? "1" : "0"}`;
res = await f(`/api/invoice/${encodeURIComponent(invoiceId)}?${query}`, signal ? { signal } : undefined);
} catch {
await backOff();
continue;
}
if (res.status === 404) return decodeError(res);
if (res.status === 429) {
const retryAfter = retryAfterSeconds(res);
await drain(res);
if (retryAfter !== undefined) {
await sleep(retryAfter * 1000, signal);
continue;
}
await backOff();
continue;
}
if (!res.ok) {
await drain(res);
await backOff();
continue;
}
let view: InvoiceView;
try {
const raw = await res.json();
const parsed = parseInvoiceView(raw);
if (!parsed) throw new Error("malformed GET 200 body");
view = parsed;
} catch {
await backOff();
continue;
}
if (view.status !== seen || changed(paymentMark(view), seenPayment)) return view;
fastRepeats = (now() - startedAt) < FAST_THRESHOLD_MS ? fastRepeats + 1 : 0;
if (fastRepeats >= FAST_LIMIT) {
await backOff();
} else {
backoff = BACKOFF_START;
}
}
}
@@ -0,0 +1,71 @@
export type BadgeType = "supporter" | "legend";
export interface Price {
priceId: string;
badgeType: BadgeType;
monthPrice: number; // minor units
currency: string;
}
export type Discount =
| { type: "freeMonths"; freeMonths: number }
| { type: "discount"; discount: number };
export interface Offer {
offerId: string;
priceId: string;
months: number;
discount: Discount;
}
export interface Catalog {
prices: readonly Price[];
offers: readonly Offer[];
}
export const CATALOG: Catalog = {
prices: [
{ priceId: "price_supporter", badgeType: "supporter", monthPrice: 700, currency: "usd" },
{ priceId: "price_legend", badgeType: "legend", monthPrice: 7000, currency: "usd" },
],
offers: [
{ offerId: "offer_3m", priceId: "price_legend", months: 3, discount: { type: "freeMonths", freeMonths: 1 } },
{ offerId: "offer_12m", priceId: "price_legend", months: 12, discount: { type: "discount", discount: 50 } },
{ offerId: "offer_3m_s", priceId: "price_supporter", months: 3, discount: { type: "freeMonths", freeMonths: 1 } },
{ offerId: "offer_12m_s", priceId: "price_supporter", months: 12, discount: { type: "discount", discount: 50 } },
],
};
// This key names no row in CATALOG.offers, and the request must omit the offerId when it is chosen.
export const SINGLE_MONTH = "1m";
export interface Total { months: number; price: number; amount: number }
const MAX_AMOUNT = 100_000_000; // $1,000,000 in minor units
export function offerTotal(monthPrice: number, offer: Offer | undefined): Total | string {
if (!Number.isInteger(monthPrice) || monthPrice < 0) return "bad month price";
if (offer === undefined) return charge(1, monthPrice);
if (!Number.isInteger(offer.months) || offer.months < 0) return "months must be a non-negative integer";
if (offer.months <= 0) return "zero months";
const gross = monthPrice * offer.months;
if (offer.discount.type === "freeMonths") {
if (!Number.isInteger(offer.discount.freeMonths) || offer.discount.freeMonths < 0) return "free months must be a non-negative integer";
if (offer.discount.freeMonths >= offer.months) return "free months exceed the term";
return charge(offer.months, monthPrice * (offer.months - offer.discount.freeMonths), gross);
}
if (!Number.isInteger(offer.discount.discount) || offer.discount.discount < 0) return "discount must be a non-negative integer";
if (offer.discount.discount >= 100) return "discount too large";
return charge(offer.months, Math.floor((gross * (100 - offer.discount.discount)) / 100), gross);
}
// The full price is checked against the cap as well as the charge, because a total the page shows as sellable would otherwise be refused at checkout.
function charge(months: number, amount: number, price = amount): Total | string {
if (amount <= 0 || amount > MAX_AMOUNT || price > MAX_AMOUNT) return "amount unsellable";
return { months, price, amount };
}
export function savingPercent(price: number, amount: number): number {
if (price <= 0) return 0;
return Math.round(((price - amount) / price) * 100);
}
@@ -0,0 +1,79 @@
// Every rule here mirrors src/Simplex/Chat/Badges/Code.hs; any divergence makes a code sold here unredeemable.
// Crockford base32: the digits and the upper-case letters except I, L, O and U.
export const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const BASE = ALPHABET.length;
const CODE_LENGTH = 20;
export const PAYLOAD = CODE_LENGTH - 1;
const GROUP = 5;
const GROUPS = new RegExp(`.{1,${GROUP}}`, "g");
const PREFIX = "SB";
// I and L are read as 1, O as 0, so a code copied by hand still verifies.
function charValue(c: string): number | undefined {
const u = c.toUpperCase();
if (u === "I" || u === "L") return 1;
if (u === "O") return 0;
const at = ALPHABET.indexOf(u);
return at < 0 ? undefined : at;
}
// Luhn mod N with N = 32, folded from the right because the check character sits to the right of the payload.
function checkValue(payload: readonly number[]): number {
let sum = 0;
let factor = 2;
for (let i = payload.length - 1; i >= 0; i--) {
const addend = factor * payload[i]!;
sum += Math.floor(addend / BASE) + (addend % BASE);
factor = factor === 2 ? 1 : 2;
}
return (BASE - (sum % BASE)) % BASE;
}
export function checkChar(body: string): string {
const values = [...body].map((c) => {
const v = charValue(c);
if (v === undefined) throw new Error(`character outside the alphabet: ${c}`);
return v;
});
return ALPHABET[checkValue(values)]!;
}
export function canonical(code: string): string {
return PREFIX + code;
}
export function display(code: string): string {
return [PREFIX, ...(code.match(GROUPS) ?? [])].join("-");
}
export function normalise(input: string): string | null {
// The service filters with Unicode `isAlphaNum`, so stripping only ASCII here would accept codes the service rejects.
const cleaned = input.replace(/[^\p{L}\p{N}]/gu, "").toUpperCase();
if (!cleaned.startsWith(PREFIX)) return null;
const body = cleaned.slice(PREFIX.length);
if (body.length !== CODE_LENGTH) return null;
const values: number[] = [];
for (const c of body) {
const v = charValue(c);
if (v === undefined) return null;
values.push(v);
}
if (values[PAYLOAD] !== checkValue(values.slice(0, PAYLOAD))) return null;
return values.map((v) => ALPHABET[v]!).join("");
}
/** 256 is a multiple of 32, so a byte reduces without bias and nothing is redrawn. */
export function generate(): string {
const buf = new Uint8Array(PAYLOAD);
crypto.getRandomValues(buf);
const payload = [...buf].map((b) => b % BASE);
return [...payload, checkValue(payload)].map((v) => ALPHABET[v]!).join("");
}
export async function hash(normalised: string): Promise<string> {
const bytes = new TextEncoder().encode(canonical(normalised));
const digest = await crypto.subtle.digest("SHA-256", bytes);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
@@ -0,0 +1,26 @@
import type { BadgeType } from "./catalog.js";
// The equity crowdfunding on Wefunder, while the round is open. Set ACTIVE to false when it closes
// and every invest panel disappears without touching a screen.
export const CROWDFUNDING_ACTIVE = true;
export const WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=badges";
// The badge each investment amount buys as a perk, in dollars. The months are the catalog's terms,
// so a chosen tier and term always names one amount.
export const INVESTOR_PERKS: Readonly<Record<BadgeType, Readonly<Record<number, number>>>> = {
supporter: { 1: 100, 3: 250, 12: 1000 },
legend: { 1: 1000, 3: 2500, 12: 10000 },
};
export function perkAmount(badgeType: BadgeType, months: number): number | undefined {
return INVESTOR_PERKS[badgeType]?.[months];
}
export function minimumFor(badgeType: BadgeType): number {
return Math.min(...Object.values(INVESTOR_PERKS[badgeType]));
}
export function dollars(amount: number): string {
return `$${amount.toLocaleString("en-US")}`;
}
@@ -0,0 +1,80 @@
import { oneOf } from "./parse.js";
export const STEPS = ["tier", "months", "checkout"] as const;
export const THEMES = ["light", "dark", "system"] as const;
const ORDER_STATUSES = ["open", "paid", "expired"] as const;
const METHODS = ["card", "btc", "xmr"] as const;
const CHAINS = ["btc", "xmr"] as const;
export type Step = (typeof STEPS)[number];
export type Theme = (typeof THEMES)[number];
export type OrderStatus = (typeof ORDER_STATUSES)[number];
export type Method = (typeof METHODS)[number];
export type Chain = (typeof CHAINS)[number];
export const readStep = oneOf(STEPS);
export const readTheme = oneOf(THEMES);
export const readStatus = oneOf(ORDER_STATUSES);
export const readMethod = oneOf(METHODS);
export const readChain = oneOf(CHAINS);
/** The optional fields are written `| undefined` so a patch can set one back to undefined to clear it. */
export interface SessionRecord {
step: Step;
priceId?: string | undefined;
offerId?: string | undefined;
method?: Method | undefined;
}
export interface OrderIdentity {
orderId: string;
badgeType: string;
months: number;
createdAt: string;
}
/** The service never sends these fields back, so nothing it returns can clear them. */
export interface OrderLocalState {
code: string | undefined;
submitted: boolean | undefined;
canceled: boolean | undefined;
method: Method | undefined;
}
/** Every field is a required key even when undefined, so a withdrawn field cannot survive in the stored record. */
export interface OrderServerState {
status: OrderStatus;
amount: number | undefined;
currency: string | undefined;
amountPaid: number | undefined;
cryptoAmountPaid: string | undefined;
cryptoAmountDue: string | undefined;
paidInFull: boolean | undefined;
address: string | undefined;
cryptoAmount: string | undefined;
cryptoCurrency: Chain | undefined;
expiresAt: string | undefined;
}
type OrderLocalFields = Partial<OrderLocalState>;
type OrderServerFields = Partial<OrderServerState> & { status: OrderStatus };
export type OrderRecord = OrderIdentity & OrderLocalFields & OrderServerFields;
function identityOf(o: OrderIdentity): OrderIdentity {
const { orderId, badgeType, months, createdAt } = o;
return { orderId, badgeType, months, createdAt };
}
export function definedOnly<Complete extends object, Sparse>(all: Complete): Sparse {
const present = Object.entries(all).filter(([, v]) => v !== undefined);
return Object.fromEntries(present) as unknown as Sparse;
}
export function composeOrder(
identity: OrderIdentity,
local: OrderLocalState,
server: OrderServerState,
): OrderRecord {
return definedOnly({ ...identityOf(identity), ...local, ...server });
}
@@ -0,0 +1,61 @@
import { readTheme } from "./domain.js";
import type { Theme } from "./domain.js";
export const THEME_MESSAGE = "simplex-theme";
export const EMBED_READY = "simplex-embed-ready";
export const ROUTE_MESSAGE = "simplex-route";
export const HEIGHT_MESSAGE = "simplex-height";
export const COLORS_MESSAGE = "simplex-colors";
export const NEW_PURCHASE_MESSAGE = "simplex-new-purchase";
export const RETURN_URL_MESSAGE = "simplex-return-url";
export const NAV_MESSAGE = "simplex-nav";
export function isNewPurchaseMessage(data: unknown): boolean {
return typeof data === "object" && data !== null && (data as { type?: unknown }).type === NEW_PURCHASE_MESSAGE;
}
export function bgFromMessage(data: unknown): string | undefined {
if (typeof data !== "object" || data === null) return undefined;
const d = data as { type?: unknown; bg?: unknown };
if (d.type !== COLORS_MESSAGE || typeof d.bg !== "string") return undefined;
return /^#[0-9a-fA-F]{3,8}$/.test(d.bg) ? d.bg : undefined;
}
const ROUTE_HASHES: readonly string[] = ["", "/", "#/tier", "#/months", "#/checkout", "#/codes"];
export function routeFromMessage(data: unknown): string | undefined {
if (typeof data !== "object" || data === null) return undefined;
const d = data as { type?: unknown; hash?: unknown };
if (d.type !== ROUTE_MESSAGE || typeof d.hash !== "string") return undefined;
return ROUTE_HASHES.includes(d.hash) ? d.hash : undefined;
}
export function returnUrlFromMessage(data: unknown): string | undefined {
if (typeof data !== "object" || data === null) return undefined;
const d = data as { type?: unknown; url?: unknown };
if (d.type !== RETURN_URL_MESSAGE || typeof d.url !== "string") return undefined;
try {
const u = new URL(d.url);
return u.protocol === "https:" || u.protocol === "http:" ? d.url : undefined;
} catch {
return undefined;
}
}
/** The dotted-suffix check on `.simplex.chat` turns away `simplex.chat.attacker.com` and `notsimplex.chat`. */
export function trustedHost(origin: string): boolean {
let u: URL;
try {
u = new URL(origin);
} catch {
return false;
}
return u.protocol === "https:" && (u.hostname === "simplex.chat" || u.hostname.endsWith(".simplex.chat"));
}
export function themeFromMessage(data: unknown): Theme | undefined {
if (typeof data !== "object" || data === null) return undefined;
const d = data as { type?: unknown; theme?: unknown };
if (d.type !== THEME_MESSAGE) return undefined;
return readTheme(d.theme);
}
+381
View File
@@ -0,0 +1,381 @@
import {
ApiError, AbortedError, BACKOFF_MAX, BACKOFF_START, NOTHING_RECEIVED, createInvoice, inferMethod,
paymentMark, readInvoice, waitForChange,
type CreatedInvoice, type InvoiceView, type PaymentMark, type Sleep,
} from "./api.js";
import { display } from "./codes.js";
import { composeOrder } from "./domain.js";
import {
applyView, closedInvoice, offlineInvoice, recordFromView, serverState, viewFor, withoutCode,
type PaymentView,
} from "./order.js";
import type { Method, OrderRecord, OrderStatus } from "./domain.js";
import type { Store } from "./store.js";
export const GIVE_UP_MS = 15 * 60 * 1000;
export const CODE_ATTEMPTS = 5;
export const DEFAULT_RETRY_AFTER_SECONDS = 60;
const HISTORY_REFRESH_LIMIT = 10;
export interface Selection {
priceId: string;
offerId?: string;
method: Method;
}
export type CheckoutOutcome =
| { kind: "created"; order: OrderRecord; invoice: CreatedInvoice; method: Method; savedLocally: boolean }
| { kind: "catalogChanged" }
| { kind: "rateLimited"; retryAfter: number }
| { kind: "providerUnavailable"; method: Method }
| { kind: "failed" };
export interface FlowDeps {
store: Store;
fetch: typeof fetch;
sleep: Sleep;
now: () => number;
newCode: () => string;
hashCode: (code: string) => Promise<string>;
render: (view: PaymentView) => void;
}
class GaveUp extends Error {
constructor() {
super("the confirming screen gave up");
this.name = "GaveUp";
}
}
export interface Watch {
readonly orderId: string;
suspend(): void;
resume(): void;
stop(): void;
restartOptions(): WatchOptions;
readonly done: Promise<void>;
}
export interface WatchOptions {
initial?: InvoiceView;
method?: Method;
resumed?: boolean;
record?: OrderRecord;
}
class WatchLoop implements Watch {
private suspended = false;
private stopped = false;
private started = false;
finished = false;
private ctl = new AbortController();
private wake: (() => void) | null = null;
private giveUpAt: number | undefined;
private budgetLeft: number | undefined;
private method: Method | undefined;
private record: OrderRecord | undefined;
private savedLocally = false;
readonly done: Promise<void>;
private settleDone!: () => void;
constructor(
readonly orderId: string,
private readonly d: FlowDeps,
private readonly opts: WatchOptions,
) {
this.method = opts.method;
this.record = opts.record ?? d.store.order(orderId);
this.done = new Promise<void>((resolve) => { this.settleDone = resolve; });
}
start(): void {
if (this.started) return;
this.started = true;
void this.run();
}
restartOptions(): WatchOptions {
return {
...(this.method !== undefined ? { method: this.method } : {}),
...(this.opts.resumed !== undefined ? { resumed: this.opts.resumed } : {}),
...(this.record !== undefined ? { record: this.record } : {}),
};
}
suspend(): void {
if (this.suspended || this.finished) return;
this.suspended = true;
if (this.giveUpAt !== undefined) this.budgetLeft = Math.max(0, this.giveUpAt - this.d.now());
this.ctl.abort();
}
resume(): void {
if (!this.suspended || this.finished) return;
this.suspended = false;
if (this.budgetLeft !== undefined) {
this.giveUpAt = this.d.now() + this.budgetLeft;
this.budgetLeft = undefined;
}
this.ctl = new AbortController();
const wake = this.wake;
this.wake = null;
wake?.();
}
stop(): void {
if (this.finished) return;
this.stopped = true;
this.ctl.abort();
const wake = this.wake;
this.wake = null;
wake?.();
}
private park(): Promise<void> {
return new Promise<void>((resolve) => { this.wake = resolve; });
}
// A stopped loop has no screen, so painting now would draw this order's address under another
// order's URL.
private emit(view: PaymentView): void {
if (this.stopped) return;
if (view.screen === "awaitingConfirmation" && this.giveUpAt === undefined) this.giveUpAt = this.d.now() + GIVE_UP_MS;
if (view.screen !== "awaitingConfirmation") {
this.giveUpAt = undefined;
this.budgetLeft = undefined;
}
this.d.render(view);
}
private show(record: OrderRecord, invoice: InvoiceView | undefined): void {
this.emit(viewFor(record, invoice, this.method, {
savedLocally: this.savedLocally,
resumed: this.opts.resumed ?? false,
}));
}
private receive(view: InvoiceView): void {
this.method = inferMethod(view) ?? this.method;
const applied = applyView(this.d.store, this.orderId, view, this.d.now(), this.record, this.method);
this.record = applied.record;
this.savedLocally = applied.savedLocally || this.savedLocally;
this.show(this.record, view);
}
private async pass(seen: "open" | "expired", seenPayment: PaymentMark): Promise<InvoiceView> {
const wait = waitForChange(this.orderId, seen, this.d.fetch, this.d.sleep, this.ctl.signal, this.d.now, seenPayment);
if (this.giveUpAt === undefined) return wait;
const remaining = this.giveUpAt - this.d.now();
const settled = wait.then((v) => ({ ok: true as const, v }), (e: unknown) => ({ ok: false as const, e }));
if (remaining <= 0) {
this.ctl.abort();
await settled;
throw new GaveUp();
}
const deadlineCtl = new AbortController();
const deadline = this.d.sleep(remaining, deadlineCtl.signal).then(() => "timeout" as const, () => "cancelled" as const);
const first = await Promise.race([settled.then(() => "answered" as const), deadline]);
if (first === "timeout") {
this.ctl.abort();
await settled;
throw new GaveUp();
}
deadlineCtl.abort();
const r = await settled;
if (r.ok) return r.v;
throw r.e;
}
private isNotFound(e: unknown): boolean {
return e instanceof ApiError && e.code === "not_found";
}
private showUnknownOrder(): void {
this.emit({ screen: "unknownOrder", orderId: this.orderId });
}
private async run(): Promise<void> {
try {
if (this.record?.status === "paid") {
this.savedLocally = this.d.store.holdsCode(this.orderId, this.record.code);
this.show(this.record, this.opts.initial);
return;
}
let seen: "open" | "expired" = "open";
let seenPayment: PaymentMark = NOTHING_RECEIVED;
let painted = false;
const take = (view: InvoiceView): boolean => {
this.receive(view);
if (view.status === "paid") return true;
seen = view.status;
seenPayment = paymentMark(view);
painted = true;
return false;
};
let first = this.opts.initial;
if (first === undefined) {
try {
first = await readInvoice(this.orderId, this.d.fetch, this.ctl.signal);
} catch (e) {
if (this.isNotFound(e)) { this.showUnknownOrder(); return; }
first = undefined;
}
}
if (first !== undefined) {
if (take(first)) return;
} else if (this.record !== undefined) {
seen = this.record.status;
const offline = offlineInvoice(this.record);
if (offline !== undefined) {
this.method = this.record.cryptoCurrency;
this.show(this.record, offline);
painted = true;
} else if (this.record.status !== "open") {
this.show(this.record, closedInvoice(this.record));
painted = true;
}
}
// A long poll answers only on a change, so the loop reads plainly until something is on
// screen before entering one.
let backoff = BACKOFF_START;
while (!painted && !this.stopped) {
if (this.suspended) { await this.park(); continue; }
try {
await this.d.sleep(backoff, this.ctl.signal);
if (take(await readInvoice(this.orderId, this.d.fetch, this.ctl.signal))) return;
} catch (e) {
if (this.isNotFound(e)) { this.showUnknownOrder(); return; }
if (e instanceof AbortedError) continue;
backoff = Math.min(backoff * 2, BACKOFF_MAX);
}
}
if (this.stopped) return;
for (;;) {
if (this.stopped) return;
if (this.suspended) { await this.park(); continue; }
let view: InvoiceView;
try {
view = await this.pass(seen, seenPayment);
} catch (e) {
if (e instanceof AbortedError) continue;
if (e instanceof GaveUp) {
this.emit({
screen: "awaitingConfirmation", gaveUp: true, invoice: undefined,
order: withoutCode(this.record ?? recordFromView(this.orderId, { status: "open" }, this.d.now())),
});
return;
}
if (this.isNotFound(e)) this.showUnknownOrder();
return;
}
if (take(view)) return;
}
} finally {
this.finished = true;
this.settleDone();
}
}
}
export class Flow {
private readonly watches = new Map<string, WatchLoop>();
private epochCount = 0;
constructor(private readonly d: FlowDeps) {}
async checkout(sel: Selection): Promise<CheckoutOutcome> {
for (let attempt = 0; attempt < CODE_ATTEMPTS; attempt++) {
const wipes = this.d.store.wipeCount;
const code = this.d.newCode();
let created: CreatedInvoice;
try {
created = await createInvoice({
priceId: sel.priceId,
...(sel.offerId !== undefined ? { offerId: sel.offerId } : {}),
method: sel.method,
codeHash: await this.d.hashCode(code),
}, this.d.fetch);
} catch (e) {
if (e instanceof ApiError) {
switch (e.code) {
case "code_conflict": continue;
case "catalog_changed": return { kind: "catalogChanged" };
case "rate_limited": return { kind: "rateLimited", retryAfter: e.retryAfter ?? DEFAULT_RETRY_AFTER_SECONDS };
case "provider_unavailable": return { kind: "providerUnavailable", method: sel.method };
default: return { kind: "failed" };
}
}
return { kind: "failed" };
}
const order = composeOrder(
{
orderId: created.invoiceId,
badgeType: created.badgeType,
months: created.months,
createdAt: new Date(this.d.now()).toISOString(),
},
{ code: display(code), submitted: undefined, canceled: undefined, method: sel.method },
serverState({ ...created, status: "open" }, undefined),
);
// The buyer can empty this browser while the invoice is bought, so the order is saved only
// when the wipe count is unchanged.
if (this.d.store.wipeCount === wipes) this.d.store.saveOrder(order);
const savedLocally = this.d.store.holdsCode(order.orderId, order.code);
return { kind: "created", order, invoice: created, method: sel.method, savedLocally };
}
return { kind: "failed" };
}
watch(orderId: string, opts: WatchOptions = {}): Watch {
const live = this.watches.get(orderId);
if (live && !live.finished) {
live.resume();
return live;
}
const loop = new WatchLoop(orderId, this.d, opts);
this.watches.set(orderId, loop);
loop.start();
return loop;
}
checkAgain(orderId: string): Watch | null {
const prev = this.watches.get(orderId);
if (prev && !prev.finished) return null;
const loop = new WatchLoop(orderId, this.d, prev ? prev.restartOptions() : {});
this.watches.set(orderId, loop);
loop.start();
return loop;
}
liveWatches(): Watch[] {
return [...this.watches.values()].filter((w) => !w.finished);
}
stopAll(): void {
for (const w of this.watches.values()) w.stop();
this.watches.clear();
this.epochCount += 1;
}
get epoch(): number {
return this.epochCount;
}
async refreshHistory(limit = HISTORY_REFRESH_LIMIT): Promise<OrderRecord[]> {
const stale: OrderStatus[] = ["open", "expired"];
const mine = this.epochCount;
const targets = this.d.store.orders().filter((o) => stale.includes(o.status)).slice(0, limit);
for (const order of targets) {
try {
const view = await readInvoice(order.orderId, this.d.fetch);
if (this.epochCount !== mine) break;
applyView(this.d.store, order.orderId, view, this.d.now(), undefined, inferMethod(view));
} catch (e) {
if (e instanceof ApiError && e.code === "rate_limited") break;
}
}
return this.d.store.orders();
}
}
@@ -0,0 +1,49 @@
const SYMBOLS: Readonly<Record<string, string>> = { usd: "$", eur: "€", gbp: "£" };
export function money(minor: number, currency: string): string {
const symbol = SYMBOLS[currency.toLowerCase()];
const figure = (minor / 100).toFixed(2);
return symbol ? `${symbol}${figure}` : `${figure} ${currency.toUpperCase()}`;
}
export function moneyCompact(minor: number, currency: string): string {
if (minor % 100 !== 0) return money(minor, currency);
const symbol = SYMBOLS[currency.toLowerCase()];
const figure = String(Math.trunc(minor / 100));
return symbol ? `${symbol}${figure}` : `${figure} ${currency.toUpperCase()}`;
}
export function countdown(expiresAt: string | undefined, nowMs: number): string | null {
if (expiresAt === undefined) return null;
const end = Date.parse(expiresAt);
if (Number.isNaN(end)) return null;
const left = Math.floor((end - nowMs) / 1000);
if (left <= 0) return null;
const s = left % 60;
const m = Math.floor(left / 60) % 60;
const h = Math.floor(left / 3600);
const pad = (n: number) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
/** A covered invoice comes back from the provider as "0.00000000", so a due amount with no non-zero digit means covered rather than owed. */
export type Outstanding =
| { kind: "owed"; amount: string }
| { kind: "covered" }
| { kind: "unknown" };
export function outstanding(due: string | undefined): Outstanding {
if (due === undefined || !/^\d+(\.\d+)?$/.test(due)) return { kind: "unknown" };
return /[1-9]/.test(due) ? { kind: "owed", amount: due } : { kind: "covered" };
}
export function startedAgo(createdAt: string, nowMs: number): string | null {
const began = Date.parse(createdAt);
if (Number.isNaN(began)) return null;
const minutes = Math.floor((nowMs - began) / 60_000);
if (minutes < 1) return "Started less than a minute ago.";
if (minutes === 1) return "Started 1 minute ago.";
if (minutes < 60) return `Started ${minutes} minutes ago.`;
const hours = Math.floor(minutes / 60);
return hours === 1 ? "Started 1 hour ago." : `Started ${hours} hours ago.`;
}
+140
View File
@@ -0,0 +1,140 @@
import type { Method } from "./domain.js";
const SVG_NS = "http://www.w3.org/2000/svg";
function svg(attrs: Record<string, string>, ...kids: SVGElement[]): SVGElement {
const node = document.createElementNS(SVG_NS, "svg");
node.setAttribute("xmlns", SVG_NS);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
for (const kid of kids) node.append(kid);
return node;
}
function shape(tag: string, attrs: Record<string, string>): SVGElement {
const node = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
return node;
}
/** The chevron of a Back control, drawn as a stroke so it takes the control's colour. */
export function chevronLeft(): SVGElement {
return svg({ class: "chevron", viewBox: MARK_VIEWBOX, "aria-hidden": "true", focusable: "false" },
shape("path", {
d: "M15 5.5 L8.5 12 L15 18.5", fill: "none",
stroke: "currentColor", "stroke-width": "2.5", "stroke-linecap": "round", "stroke-linejoin": "round",
}));
}
export function hamburger(): SVGElement {
const box = { class: "bars", viewBox: MARK_VIEWBOX, "aria-hidden": "true", focusable: "false" };
const bar = (y: string): SVGElement => shape("line", {
x1: "3", y1: y, x2: "21", y2: y,
stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round",
});
return svg(box, bar("7"), bar("12"), bar("17"));
}
const SUN_PATHS = [
"M225.768 1.44594C220.301 3.84594 214.701 10.6459 213.768 15.9793C213.234 18.3793 213.101 31.7126 213.368 45.7126L213.768 71.1793L217.634 76.1126C226.701 88.1126 242.968 87.5793 252.301 75.0459C254.968 71.5793 255.101 69.5793 255.101 42.2459C255.101 14.9126 254.968 12.9126 252.301 9.44594C250.834 7.44594 248.034 4.51261 246.168 3.17928C241.768 -0.154057 231.101 -1.08739 225.768 1.44594Z",
"M72.9687 65.3128C63.7687 71.5794 60.1687 81.4461 63.7687 91.1794C65.502 95.4461 98.702 129.446 104.035 132.379C106.035 133.446 110.569 134.246 114.035 134.246C126.035 134.246 134.435 125.846 134.435 113.846C134.435 110.379 133.635 105.846 132.569 103.846C129.635 98.5128 95.6353 65.3128 91.3687 63.5794C85.102 61.3128 77.902 61.9794 72.9687 65.3128Z",
"M377.102 63.7128C373.102 65.3128 338.969 98.7794 336.302 103.846C335.236 105.846 334.436 110.379 334.436 113.846C334.436 125.846 342.836 134.246 354.836 134.246C358.302 134.246 362.836 133.446 364.836 132.379C370.169 129.446 403.369 95.4461 405.102 91.1794C408.702 81.4461 405.102 71.5794 395.902 65.3128C391.102 61.9794 383.102 61.3128 377.102 63.7128Z",
"M211.635 108.246C185.635 113.179 163.502 125.046 144.302 144.112C117.902 170.512 104.968 203.579 106.835 240.246C109.768 297.712 149.102 344.912 205.902 359.179C218.568 362.379 250.302 362.379 262.968 359.179C310.435 347.312 345.768 312.646 358.702 265.046C362.435 251.446 362.835 219.446 359.368 205.712C347.368 158.112 312.302 122.379 265.235 110.112C251.768 106.512 225.502 105.579 211.635 108.246Z",
"M12.4349 214.779C-0.498477 220.379 -4.09848 237.579 5.23486 248.246C11.2349 255.046 15.6349 255.846 45.3682 255.313C74.0349 254.913 75.1015 254.646 81.6349 245.713C85.9015 239.979 85.5015 228.113 80.8349 221.979C74.4349 213.713 71.2349 212.913 42.1682 212.913C23.7682 213.046 15.2349 213.579 12.4349 214.779Z",
"M396.035 215.179C393.635 216.379 390.035 219.445 388.035 221.979C383.368 228.112 382.968 239.979 387.235 245.712C394.035 254.779 394.301 254.912 426.435 254.912C458.568 254.912 458.835 254.779 465.635 245.712C469.501 240.512 469.501 227.979 465.635 222.779C458.968 213.845 458.301 213.579 428.035 213.179C404.435 212.912 399.768 213.179 396.035 215.179Z",
"M103.101 336.779C96.7013 340.779 65.368 373.179 63.768 377.313C57.368 394.513 74.168 411.313 91.368 404.913C95.6347 403.179 129.635 369.979 132.568 364.646C135.368 359.446 134.968 349.046 131.768 343.846C126.168 334.646 112.035 331.179 103.101 336.779Z",
"M344.035 336.912C341.635 338.379 338.569 341.445 337.102 343.845C333.902 349.045 333.502 359.445 336.302 364.645C339.235 369.979 373.235 403.179 377.502 404.912C387.235 408.512 397.102 404.912 403.369 395.712C406.702 390.779 407.369 383.579 405.102 377.312C403.369 373.045 370.169 339.045 364.835 336.112C359.635 333.312 349.235 333.712 344.035 336.912Z",
"M225.368 385.712C222.968 387.046 219.368 390.112 217.368 392.646L213.768 397.312V426.379C213.768 458.246 213.901 458.779 222.968 465.446C228.168 469.312 240.701 469.312 245.901 465.446C254.834 458.779 255.101 457.979 255.501 428.246C256.034 397.579 255.234 394.379 246.834 387.979C241.101 383.579 231.634 382.512 225.368 385.712Z",
];
const MOON_PATH = "M10.895 7.574c0 7.55 5.179 13.67 11.567 13.67 1.588 0 3.101-0.38 4.479-1.063-1.695 4.46-5.996 7.636-11.051 7.636-6.533 0-11.83-5.297-11.83-11.83 0-4.82 2.888-8.959 7.023-10.803-0.116 0.778-0.188 1.573-0.188 2.39z";
export function sunIcon(): SVGElement {
return svg({ class: "sun", width: "469", height: "469", viewBox: "0 0 469 469", "aria-hidden": "true", focusable: "false" },
...SUN_PATHS.map((d) => shape("path", { d })));
}
export function moonIcon(): SVGElement {
return svg({ class: "moon", width: "469", height: "469", viewBox: "5 5 20 23", "aria-hidden": "true", focusable: "false" },
shape("path", { d: MOON_PATH }));
}
const BADGE_STOPS = {
supporter: [["0%", "#29f5ff"], ["5%", "#29f5ff"], ["95%", "#527eed"], ["100%", "#3669e9"]],
legend: [["0%", "#29f5ff"], ["5%", "#26dee8"], ["50%", "#3064ea"], ["100%", "#001064"]],
} as const satisfies Readonly<Record<string, ReadonlyArray<readonly [string, string]>>>;
export type BadgeTier = keyof typeof BADGE_STOPS;
export function hasBadgeArt(tier: string): tier is BadgeTier {
return Object.prototype.hasOwnProperty.call(BADGE_STOPS, tier);
}
const BADGE_BODY = "M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z";
const BADGE_GLYPH = "M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z";
const BADGE_VIEWBOX = "8.25 8.25 300 399";
let gradientSeq = 0;
// SVG nodes must be created with createElementNS, because an <svg> made with createElement in an HTML document is an HTMLUnknownElement that draws nothing.
export function badgeIcon(tier: BadgeTier): SVGElement {
const id = `sb-badge-${tier}-${(gradientSeq += 1)}`;
const gradient = shape("linearGradient", { id, x1: "0", y1: "0", x2: "0", y2: "1" });
for (const [offset, color] of BADGE_STOPS[tier]) {
gradient.append(shape("stop", { offset, "stop-color": color }));
}
const defs = shape("defs", {});
defs.append(gradient);
return svg({ class: "badge-art", viewBox: BADGE_VIEWBOX, "aria-hidden": "true", focusable: "false" },
defs,
shape("path", { fill: `url(#${id})`, d: BADGE_BODY }),
shape("path", { fill: "#ffffff", "fill-rule": "nonzero", d: BADGE_GLYPH }),
);
}
const MARK_VIEWBOX = "0 0 24 24";
const BITCOIN = "M23.638 14.904c-1.602 6.43-8.113 10.34-14.542 8.736C2.67 22.05-1.244 15.525.362 9.105 1.962 2.67 8.475-1.243 14.9.358c6.43 1.605 10.342 8.115 8.738 14.548v-.002zm-6.35-4.613c.24-1.59-.974-2.45-2.64-3.03l.54-2.153-1.315-.33-.525 2.107c-.345-.087-.705-.167-1.064-.25l.526-2.127-1.32-.33-.54 2.165c-.285-.067-.565-.132-.84-.2l-1.815-.45-.35 1.407s.975.225.955.236c.535.136.63.486.615.766l-1.477 5.92c-.075.166-.24.406-.614.314.015.02-.96-.24-.96-.24l-.66 1.51 1.71.426.93.242-.54 2.19 1.32.327.54-2.17c.36.1.705.19 1.05.273l-.51 2.154 1.32.33.545-2.19c2.24.427 3.93.257 4.64-1.774.57-1.637-.03-2.58-1.217-3.196.854-.193 1.5-.76 1.68-1.93h.01zm-3.01 4.22c-.404 1.64-3.157.75-4.05.53l.72-2.9c.896.23 3.757.67 3.33 2.37zm.41-4.24c-.37 1.49-2.662.735-3.405.55l.654-2.64c.744.18 3.137.524 2.75 2.084v.006z";
// The Monero mark as simplex.domains draws it: the official two-colour glyph in its own 256 box,
// scaled into the 24 box the other marks share.
const MONERO_TOP = "M127.998 0C57.318 0 0 57.317 0 127.999c0 14.127 2.29 27.716 6.518 40.43H44.8V60.733l83.2 83.2 83.198-83.2v107.695h38.282c4.231-12.714 6.521-26.303 6.521-40.43C256 57.314 198.681 0 127.998 0";
const MONERO_BASE = "M108.867 163.062l-36.31-36.311v67.765H18.623c22.47 36.863 63.051 61.48 109.373 61.48s86.907-24.617 109.374-61.48h-53.933V126.75l-36.31 36.31-19.13 19.129-19.128-19.128h-.002z";
const MONERO_SCALE = "scale(0.09375)";
const CARD_STROKE = "currentColor";
// The Wefunder wordmark, as simplex.chat/crowdfunding draws it: the W is a stroke, the letters a fill,
// both in currentColor so the pill sets the colour.
const WEFUNDER_LETTERS = "M44.0386 38.5332H56.7241V35.3848H47.5317V31.6849H55.5291V28.5365H47.5317V24.9515H56.6092V21.8031H44.0386V38.5332ZM60.242 38.5332H63.7581V31.9836H71.8474V28.7663H63.7581V25.0204H72.9045V21.8031H60.242V38.5332ZM83.049 38.786C87.5302 38.786 90.3569 36.3041 90.3569 31.2482V21.8031H86.8408V31.4091C86.8408 34.1438 85.416 35.5457 83.0949 35.5457C80.7738 35.5457 79.372 34.0749 79.372 31.2712V21.8031H75.8329V31.3861C75.8329 36.2811 78.5906 38.786 83.049 38.786ZM94.3543 38.5332H97.8245V27.6402L106.144 38.5332H109.154V21.8031H105.661V32.3513L97.5946 21.8031H94.3543V38.5332ZM113.43 38.5332H119.888C125.151 38.5332 128.782 34.8792 128.782 30.1681V30.1222C128.782 25.4111 125.151 21.8031 119.888 21.8031H113.43V38.5332ZM119.888 24.9974C122.99 24.9974 125.082 27.1346 125.082 30.1681V30.2141C125.082 33.2476 122.99 35.3388 119.888 35.3388H116.946V24.9974H119.888ZM132.304 38.5332H144.99V35.3848H135.797V31.6849H143.795V28.5365H135.797V24.9515H144.875V21.8031H132.304V38.5332ZM152.024 29.9613V24.9974H155.816C157.677 24.9974 158.826 25.8477 158.826 27.4564V27.5023C158.826 28.9731 157.723 29.9613 155.885 29.9613H152.024ZM148.508 38.5332H152.024V33.0867H155.08L158.78 38.5332H162.917L158.734 32.4892C160.894 31.7308 162.388 30.0302 162.388 27.3185V27.2725C162.388 25.7328 161.906 24.4229 160.986 23.5037C159.883 22.4006 158.229 21.8031 156.114 21.8031H148.508V38.5332Z";
const WEFUNDER_W = "M3 29.4853L6.36524 22.194C6.60628 21.6717 7.35366 21.6867 7.57359 22.2182L14.3487 38.5913C14.5779 39.1453 15.3677 39.1308 15.5764 38.5687L23.1127 18.279C23.315 17.7341 24.0721 17.6984 24.3249 18.2217L32.5064 35.1596C32.7713 35.7081 33.5744 35.6351 33.7362 35.048L43.1168 1";
export function wefunderMark(): SVGElement {
return svg({ class: "wefunder", viewBox: "0 0 165 45", "aria-hidden": "true", focusable: "false" },
shape("path", { fill: "currentColor", d: WEFUNDER_LETTERS }),
shape("path", { fill: "none", stroke: "currentColor", "stroke-width": "4.86264", d: WEFUNDER_W }),
);
}
export function methodMark(method: Method): SVGElement {
const box = { class: "mark", viewBox: MARK_VIEWBOX, "aria-hidden": "true", focusable: "false" };
switch (method) {
case "btc":
return svg(box, shape("path", { fill: "#F7931A", d: BITCOIN }));
case "xmr": {
const glyph = shape("g", { transform: MONERO_SCALE });
glyph.append(shape("path", { fill: "#F60", d: MONERO_TOP }), shape("path", { fill: "#4C4C4C", d: MONERO_BASE }));
return svg(box, glyph);
}
case "card":
return svg(box,
shape("rect", {
x: "2", y: "5", width: "20", height: "14", rx: "2",
fill: "none", stroke: CARD_STROKE, "stroke-width": "2",
}),
shape("line", {
x1: "2", y1: "10", x2: "22", y2: "10",
stroke: CARD_STROKE, "stroke-width": "2",
}),
);
}
}
+930
View File
@@ -0,0 +1,930 @@
import { CATALOG, SINGLE_MONTH, offerTotal, savingPercent, type Offer, type Price, type Total } from "./catalog.js";
import { minimumFor, perkAmount } from "./crowdfunding.js";
import { generate, hash } from "./codes.js";
import { Flow, type CheckoutOutcome, type Selection } from "./flow.js";
import { applyView, historyRows, selectionFromOrder, type PaymentView, withoutDestination } from "./order.js";
import { money, moneyCompact } from "./format.js";
import * as api from "./api.js";
import { resolveLoad } from "./routing.js";
import * as screens from "./screens.js";
import { appearanceFor, cardPlan, loadStripeJs, mountCard, publishableKey, type CardFailure, type ConfirmOutcome } from "./stripe.js";
import { Store, type StorageLike } from "./store.js";
import { STEPS } from "./domain.js";
import type { Method, OrderRecord, SessionRecord, Step, Theme } from "./domain.js";
import { EMBED_READY, HEIGHT_MESSAGE, NAV_MESSAGE, THEME_MESSAGE, bgFromMessage, isNewPurchaseMessage, returnUrlFromMessage, routeFromMessage, themeFromMessage, trustedHost } from "./embed.js";
const app = document.getElementById("app");
if (app === null) throw new Error("main: #app is missing from the shell");
const root = app;
const pageFetch: typeof fetch = (input, init) => window.fetch(input, init);
const chromeSlot = document.getElementById("chrome");
if (chromeSlot === null) throw new Error("main: #chrome is missing from the shell");
function pageStore(): Store {
const probe = "sb.probe";
let real: StorageLike | undefined;
try {
const s = window.localStorage;
real = s;
s.setItem(probe, "1");
const durable = s.getItem(probe) === "1";
if (durable) {
s.removeItem(probe);
return new Store(s, true);
}
} catch {}
try { real?.removeItem(probe); } catch {}
return new Store(memoryOver(real), false);
}
function memoryOver(real: StorageLike | undefined): StorageLike {
const m = new Map<string, string>();
return {
getItem: (k) => {
const held = m.get(k);
if (held !== undefined) return held;
try { return real?.getItem(k) ?? null; } catch { return null; }
},
setItem: (k, v) => { m.set(k, v); },
removeItem: (k) => {
m.delete(k);
try { real?.removeItem(k); } catch {}
},
};
}
const store = pageStore();
const flow = new Flow({
store,
fetch: pageFetch,
sleep: api.realSleep,
now: Date.now,
newCode: generate,
hashCode: hash,
render: (view) => { paint(view); },
});
const embedded = window.self !== window.top;
const chromeUi = screens.chrome({
onNewPurchase: startPurchase,
onHistory: showCodes,
theme: store.theme(),
onTheme: (theme) => { setTheme(theme, true); },
onToggle: (open) => {
for (const node of [root, document.getElementById("contact")]) {
if (open) node?.setAttribute("inert", "");
else node?.removeAttribute("inert");
}
},
onHome: () => goToIndex(0),
});
if (embedded) document.documentElement.classList.add("embedded");
else chromeSlot.replaceChildren(chromeUi.node);
const THEME_ATTRIBUTE = "data-theme";
function applyTheme(theme: Theme): void {
const html = document.documentElement;
if (theme === "system") html?.removeAttribute(THEME_ATTRIBUTE);
else html?.setAttribute(THEME_ATTRIBUTE, theme);
// The site's navbar keys its dark palette on a `.dark` class, so mirror the resolved theme onto it while styles.css reads data-theme.
html?.classList.toggle("dark", theme === "dark" || (theme === "system" && prefersDark()));
chromeUi.showTheme(theme);
}
let hostOrigin: string | undefined;
let hostReturnUrl: string | undefined;
// echo is false for a theme that arrived from the host, so applying it does not post it straight back to the host.
function setTheme(theme: Theme, echo: boolean): void {
store.saveTheme(theme);
applyTheme(theme);
if (lastView?.screen === "cardForm") paint(lastView);
if (echo && embedded && hostOrigin !== undefined) {
window.parent.postMessage({ type: THEME_MESSAGE, theme }, hostOrigin);
}
}
let welcomeHeight = 0;
function measureWelcome(): void {
const landing = panels[0];
if (landing === undefined || root.firstChild !== track) return;
const pad = Number.parseFloat(getComputedStyle(document.body).paddingTop) || 0;
const measured = Math.ceil(landing.getBoundingClientRect().height + pad);
if (measured > 0) welcomeHeight = measured;
}
let lastHeight = 0;
function postHeight(): void {
measureWelcome();
// Rounding the fractional box height down leaves the iframe a sub-pixel short and the footer rides over the last row.
const height = Math.ceil(document.documentElement.getBoundingClientRect().height);
if (height === lastHeight) return;
lastHeight = height;
window.parent.postMessage({ type: HEIGHT_MESSAGE, height, min: welcomeHeight }, hostOrigin ?? "*");
}
function announceLocation(): void {
if (!embedded || hostOrigin === undefined) return;
window.parent.postMessage({ type: NAV_MESSAGE, hash: location.hash }, hostOrigin);
}
if (embedded) {
window.addEventListener("message", (event) => {
if (!trustedHost(event.origin)) return;
if (hostOrigin === undefined) { hostOrigin = event.origin; postHeight(); }
const theme = themeFromMessage(event.data);
if (theme !== undefined) { hostOrigin = event.origin; setTheme(theme, false); return; }
if (isNewPurchaseMessage(event.data)) { hostOrigin = event.origin; startPurchase(); return; }
const hash = routeFromMessage(event.data);
if (hash !== undefined) { hostOrigin = event.origin; applyRoute(hash); return; }
const returnUrl = returnUrlFromMessage(event.data);
if (returnUrl !== undefined) { hostOrigin = event.origin; hostReturnUrl = returnUrl; return; }
const bg = bgFromMessage(event.data);
if (bg !== undefined) { hostOrigin = event.origin; document.documentElement.style.setProperty("--bg", bg); }
});
new ResizeObserver(() => { postHeight(); }).observe(document.body);
try { window.parent.postMessage({ type: EMBED_READY, theme: store.theme() }, "*"); } catch {}
}
applyTheme(store.theme());
function prefersDark(): boolean {
return window.matchMedia?.("(prefers-color-scheme: dark)").matches === true;
}
function cardAppearance(): ReturnType<typeof appearanceFor> {
return appearanceFor(store.theme(), prefersDark());
}
// A remembered return older than this is a stale attempt, not the buyer coming back from the redirect.
const CARD_RETURN_WINDOW_MS = 15 * 60 * 1000;
// Embedded, the redirect target must be the host page, or the confirm pulls the whole tab out of the site onto the standalone app.
function cardReturnUrl(): string {
return embedded && hostReturnUrl !== undefined ? hostReturnUrl : location.origin + location.pathname;
}
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
if (store.theme() !== "system") return;
applyTheme("system");
if (lastView?.screen === "cardForm") paint(lastView);
});
function syncChrome(): void {
chromeUi.offerNewPurchase(store.newestOpen()?.submitted !== true);
}
window.addEventListener("keydown", (event) => {
if (!chromeUi.isOpen()) return;
const key = event.key;
if (key === "Escape") { chromeUi.close(); return; }
if (key !== "Tab") return;
const items = chromeUi.focusables();
if (items.length === 0) return;
const at = items.findIndex((item) => item === document.activeElement);
const back = event.shiftKey;
const next = at < 0 ? (back ? items.length - 1 : 0) : at + (back ? -1 : 1);
if (at >= 0 && next >= 0 && next < items.length) return;
event.preventDefault?.();
items[next < 0 ? items.length - 1 : next % items.length]?.focus?.();
});
document.addEventListener("click", (event) => {
const target = event.target;
if (target === null) return;
if (chromeUi.isOpen() && !chromeUi.holds(target)) chromeUi.close();
});
const TIER_LIMITS: Readonly<Record<string, { gb: number; days: number }>> = {
supporter: { gb: 2, days: 7 },
legend: { gb: 5, days: 21 },
};
function tierName(badgeType: string): string {
return badgeType.charAt(0).toUpperCase() + badgeType.slice(1);
}
function tierFeatures(badgeType: string): readonly string[] {
const limits = TIER_LIMITS[badgeType];
return limits === undefined ? [] : [`Files up to ${limits.gb} GB`, `Stored for ${limits.days} days`];
}
// The line under the duration screen's heading: what the chosen badge gives, since the tier cards are a screen back.
function tierSummary(badgeType: string): screens.ChosenTier | undefined {
const limits = TIER_LIMITS[badgeType];
return limits === undefined ? undefined
: { badge: `${tierName(badgeType)}:`, gives: `${limits.gb} GB files available for ${limits.days} days.` };
}
// The investment that earns the chosen badge as a perk: the tier's minimum until a term is chosen,
// then the exact amount for that term.
function investPerk(price: Price | undefined, months: number | undefined): screens.InvestPerk | undefined {
if (price === undefined) return undefined;
const exact = months === undefined ? undefined : perkAmount(price.badgeType, months);
return exact === undefined || months === undefined
? { badgeType: price.badgeType, amount: minimumFor(price.badgeType) }
: { badgeType: price.badgeType, amount: exact, months };
}
function withPerk<T extends object>(o: T, perk: screens.InvestPerk | undefined): T & { perk?: screens.InvestPerk } {
return perk === undefined ? o : { ...o, perk };
}
function priceOf(priceId: string | undefined): Price | undefined {
return CATALOG.prices.find((p) => p.priceId === priceId);
}
function chosenDuration(session: SessionRecord): string | undefined {
return session.offerId === undefined || session.offerId === "" ? undefined : session.offerId;
}
function offerOf(key: string | undefined): Offer | undefined {
return key === undefined || key === SINGLE_MONTH ? undefined : CATALOG.offers.find((o) => o.offerId === key);
}
function durationsFor(price: Price): Array<{ key: string; offer: Offer | undefined }> {
const offers = CATALOG.offers.filter((o) => o.priceId === price.priceId);
return [{ key: SINGLE_MONTH, offer: undefined }, ...offers.map((o) => ({ key: o.offerId, offer: o }))]
.sort((a, b) => (a.offer?.months ?? 1) - (b.offer?.months ?? 1));
}
function totalFor(price: Price | undefined, offer: Offer | undefined): Total | undefined {
if (price === undefined) return undefined;
const t = offerTotal(price.monthPrice, offer);
return typeof t === "string" ? undefined : t;
}
const HASHES: Readonly<Record<Step, string>> = { tier: "#/tier", months: "#/months", checkout: "#/checkout" };
const CODES_HASH = "#/codes";
const rail = screens.el("div", { class: "rail" });
const track = screens.el("div", { class: "track" }, rail);
const panels: HTMLElement[] = [];
let index = 0;
function landingIndex(): number {
const fromHash = STEPS.findIndex((s) => HASHES[s] === location.hash);
return fromHash >= 0 ? fromHash + 1 : 0;
}
const FIRST_INDEX_NEEDING_A_LEVEL = 2;
const CHECKOUT_INDEX = STEPS.length;
const PANEL_COUNT = STEPS.length + 1;
function reachableIndex(at: number): number {
return at >= FIRST_INDEX_NEEDING_A_LEVEL && effectiveSession().priceId === undefined ? 0 : at;
}
function hashForIndex(i: number): string {
const step = STEPS[i - 1];
return step === undefined ? "/" : HASHES[step];
}
let rateLimitStop: (() => void) | null = null;
let rateHoldStop: (() => void) | null = null;
function stopCountdowns(): void {
rateLimitStop?.();
rateLimitStop = null;
rateHoldStop?.();
rateHoldStop = null;
}
function replacePanel(at: number, node: HTMLElement): void {
if (at === CHECKOUT_INDEX) stopCountdowns();
const old = panels[at];
if (old === undefined) { panels[at] = node; return; }
rail.replaceChild(node, old);
panels[at] = node;
}
function rebuild(at: number): void {
replacePanel(at, buildPanel(at));
applyInert();
if (at === index) moveTrack(false);
}
// The order seeds the session from underneath, never over it, so a duration the buyer just picked is not overwritten by the old order.
function effectiveSession(): SessionRecord {
const session = store.session();
if (session.priceId !== undefined) return session;
const seed = selectionFromOrder(store.orders()[0], CATALOG.prices, CATALOG.offers);
return seed === undefined ? session : { ...seed, ...session };
}
function buildPanel(at: number): HTMLElement {
const session = effectiveSession();
switch (at) {
case 0:
return screens.landing({ onStart: () => goToIndex(1) });
case 1:
return screens.tiers({
tiers: CATALOG.prices.map((p) => ({
priceId: p.priceId,
badgeType: p.badgeType,
name: tierName(p.badgeType),
price: `${moneyCompact(p.monthPrice, p.currency)} / month`,
features: tierFeatures(p.badgeType),
disabled: totalFor(p, undefined) === undefined,
})),
...(session.priceId !== undefined ? { selected: session.priceId } : { selected: undefined }),
...withPerk({}, investPerk(priceOf(session.priceId), undefined)),
onSelect: (priceId) => {
unavailableMethod = undefined;
store.saveSession({ step: "tier", priceId, offerId: undefined });
rebuild(1);
rebuild(2);
rebuild(CHECKOUT_INDEX);
},
onContinue: () => goToIndex(2),
onBack: () => history.back(),
});
case 2: {
const price = priceOf(session.priceId);
const durations = price === undefined ? [] : durationsFor(price).map(({ key, offer }) => {
const total = totalFor(price, offer);
const months = offer?.months ?? 1;
const saving = total === undefined ? undefined : savingPercent(total.price, total.amount);
return {
key,
name: months === 1 ? "1 month" : `${months} months`,
...(total !== undefined ? { price: moneyCompact(total.amount, price.currency) } : {}),
...(total !== undefined && saving !== undefined && saving > 0
? { wasPrice: moneyCompact(total.price, price.currency) } : {}),
...(saving !== undefined && saving > 0 ? { savingPercent: saving } : {}),
disabled: total === undefined,
};
});
const chosenKey = chosenDuration(session);
const chosen = chosenKey === undefined ? undefined : totalFor(price, offerOf(chosenKey));
const tier = price === undefined ? undefined : tierSummary(price.badgeType);
return screens.durations({
durations,
selected: chosenKey,
...(tier !== undefined ? { tier } : {}),
...withPerk({}, investPerk(price, chosen?.months)),
onSelect: (key) => { store.saveSession({ step: "months", offerId: key }); rebuild(2); rebuild(CHECKOUT_INDEX); },
onContinue: () => goToIndex(CHECKOUT_INDEX),
onBack: () => history.back(),
});
}
default: {
const price = priceOf(session.priceId);
const offer = offerOf(chosenDuration(session));
const total = totalFor(price, offer);
const method = session.method ?? "xmr";
const open = store.newestOpen();
return screens.orderSummary({
canKeepTheCode: store.canHoldACode(),
badgeType: price?.badgeType ?? "",
months: total?.months ?? 1,
total: total === undefined ? "" : money(total.amount, price?.currency ?? "usd"),
...(total !== undefined && total.price > total.amount
? { discount: {
price: money(total.price, price?.currency ?? "usd"),
off: money(total.price - total.amount, price?.currency ?? "usd"),
...(savingPercent(total.price, total.amount) > 0 ? { percent: savingPercent(total.price, total.amount) } : {}),
} }
: {}),
selected: method,
...withPerk({}, investPerk(price, total?.months)),
...(unavailableMethod !== undefined ? { unavailable: unavailableMethod } : {}),
...(open !== undefined
? { openOrder: {
orderId: open.orderId,
...(open.submitted === true ? { awaitingCard: true } : {}),
onOpen: goToOrder,
} }
: {}),
onSelect: (m) => { store.saveSession({ step: "checkout", method: m }); rebuild(CHECKOUT_INDEX); },
onPay: () => { void pay(); },
onBack: () => history.back(),
});
}
}
}
function applyInert(): void {
panels.forEach((p, i) => {
if (i === index) p.removeAttribute("inert");
else p.setAttribute("inert", "");
});
}
function reducedMotion(): boolean {
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}
const SLIDE_MS = 320;
function panelHeight(at: number): number | null {
const box = panels[at]?.getBoundingClientRect?.();
const height = box === undefined ? 0 : Math.ceil(box.height);
return height > 0 ? height : null;
}
function moveTrack(smooth: boolean): void {
const slide = `--slide:${smooth && !reducedMotion() ? SLIDE_MS : 0}ms`;
rail.setAttribute("style", `${slide};transform:translateX(-${index * 100}%)`);
const height = panelHeight(index);
track.setAttribute("style", height === null ? slide : `${slide};height:${height}px`);
}
function goToIndex(at: number): void {
const step = STEPS[at - 1];
if (step !== undefined) store.saveSession({ step });
history.pushState(null, "", hashForIndex(at));
showIndex(at, true);
announceLocation();
}
function showIndex(at: number, smooth: boolean): void {
if (at === 0) unavailableMethod = undefined;
lastView = null;
releaseCardElement();
const freshMount = panels.length === 0 || root.firstChild !== track;
if (panels.length === 0) {
for (let i = 0; i < PANEL_COUNT; i++) panels.push(buildPanel(i));
rail.replaceChildren(...panels);
} else {
replacePanel(at, buildPanel(at));
}
if (root.firstChild !== track) root.replaceChildren(track);
index = at;
applyInert();
syncChrome();
moveTrack(smooth && !freshMount);
}
let unavailableMethod: Method | undefined;
let checkoutInFlight = false;
async function pay(): Promise<void> {
if (checkoutInFlight) return;
const session = effectiveSession();
const price = priceOf(session.priceId);
if (price === undefined) return;
if (store.newestOpen()?.submitted === true) return;
const duration = chosenDuration(session);
const sel: Selection = {
priceId: price.priceId,
...(duration !== undefined && duration !== SINGLE_MONTH ? { offerId: duration } : {}),
method: session.method ?? "xmr",
};
const payButton = panels[CHECKOUT_INDEX]?.querySelector("button.primary");
if (payButton === null || payButton === undefined || payButton.hasAttribute("disabled")) return;
payButton.setAttribute("disabled", "");
// The disabled attribute does not survive a repaint, so this flag stops a second checkout buying a second invoice.
checkoutInFlight = true;
const since = flow.epoch;
let outcome: CheckoutOutcome;
try {
outcome = await flow.checkout(sel);
} finally {
checkoutInFlight = false;
}
if (flow.epoch !== since) return;
switch (outcome.kind) {
case "created": {
store.clearSession();
history.replaceState(null, "", `?order=${encodeURIComponent(outcome.order.orderId)}`);
announceLocation();
unavailableMethod = undefined;
root.replaceChildren(screens.loading());
flow.watch(outcome.order.orderId, {
initial: { status: "open", ...outcome.invoice },
method: outcome.method,
record: outcome.order,
});
return;
}
case "catalogChanged":
root.replaceChildren(screens.catalogChanged(() => {
newInvoice();
}));
return;
case "rateLimited": {
const screen = screens.rateLimited(
{ total: payTotal(), method: sel.method, seconds: outcome.retryAfter, onBack: () => history.back() },
() => { if (index === CHECKOUT_INDEX && root.firstChild === track) rebuild(CHECKOUT_INDEX); },
);
replacePanel(CHECKOUT_INDEX, screen.node);
rateLimitStop = screen.stop;
applyInert();
return;
}
case "providerUnavailable":
unavailableMethod = outcome.method;
store.saveSession({ method: firstAvailable(outcome.method) });
rebuild(CHECKOUT_INDEX);
return;
case "failed":
replacePanel(CHECKOUT_INDEX, screens.invoiceFailure({
...withPerk({}, investPerk(price, totalFor(price, offerOf(duration))?.months)),
onBack: () => { rebuild(CHECKOUT_INDEX); },
onRetry: () => { rebuild(CHECKOUT_INDEX); void pay(); },
}));
applyInert();
moveTrack(false);
return;
}
}
function payTotal(): string {
const session = effectiveSession();
const price = priceOf(session.priceId);
const total = totalFor(price, offerOf(chosenDuration(session)));
return total === undefined ? "" : money(total.amount, price?.currency ?? "usd");
}
// A coin that is down falls back to the other coin, and the card to the first coin: a buyer who
// chose a coin is not moved to a card.
function firstAvailable(down: Method): Method {
return screens.METHOD_ORDER.find((m) => m !== "card" && m !== down) ?? "card";
}
let cancelNotice: { orderId: string; epoch: number; message: string } | undefined;
// The watch was stopped before the request, so restart it or the page sits on a screen nothing updates.
function refuseCancel(orderId: string, resume: Parameters<typeof flow.watch>[1], message: string): never {
flow.watch(orderId, resume);
throw new screens.CancelRefused(message);
}
let cancelInFlight = false;
async function cancelInvoice(orderId: string): Promise<void> {
if (!window.confirm(screens.CANCEL_CONFIRM)) return;
cancelNotice = undefined;
const { record: _record, ...resume } = flow.liveWatches().find((w) => w.orderId === orderId)?.restartOptions() ?? {};
flow.stopAll();
const since = flow.epoch;
let cancelled: api.InvoiceView | undefined;
try {
cancelled = await api.cancelInvoice(orderId, pageFetch);
if (store.order(orderId) !== undefined) applyView(store, orderId, cancelled, Date.now());
if (flow.epoch !== since) {
const live = flow.liveWatches().find((w) => w.orderId === orderId);
if (live !== undefined) {
// flow.watch hands back the existing unfinished loop, so the stopped loop must be awaited before restarting.
live.stop();
await live.done;
flow.watch(orderId, { initial: cancelled });
}
return;
}
} catch (e) {
const code = e instanceof api.ApiError ? e.code : undefined;
if (code === "not_open") {
const held = store.order(orderId);
if (held !== undefined) store.saveOrder(withoutDestination(held));
}
if (flow.epoch !== since) return;
if (code === "not_open") refuseCancel(orderId, resume, screens.CANCEL_NOT_OPEN);
cancelNotice = {
orderId,
epoch: flow.epoch,
message: code === "funded" ? screens.CANCEL_HAS_FUNDS : screens.CANCEL_FAILED,
};
if (code === "funded") refuseCancel(orderId, resume, screens.CANCEL_HAS_FUNDS);
flow.watch(orderId, resume);
throw e;
}
if (cancelled.status === "open") {
cancelNotice = { orderId, epoch: flow.epoch, message: screens.CANCEL_STILL_OPEN };
refuseCancel(orderId, resume, screens.CANCEL_STILL_OPEN);
}
if (cancelled.paidInFull === true || cancelled.cryptoAmountPaid !== undefined) {
flow.watch(orderId, resume);
return;
}
store.markCanceled(orderId);
newInvoice();
}
// The Cancel button's disabled attribute does not survive a repaint, so this flag allows one cancel at a time.
async function cancelInvoiceOnce(orderId: string): Promise<void> {
if (cancelInFlight) return;
cancelInFlight = true;
try {
await cancelInvoice(orderId);
} finally {
cancelInFlight = false;
}
}
function resetToLanding(nav: "push" | "replace"): void {
stopCountdowns();
flow.stopAll();
store.clearCardReturn();
if (nav === "push") history.pushState(null, "", "/");
else history.replaceState(null, "", "/");
panels.length = 0;
showIndex(0, false);
announceLocation();
}
function newInvoice(): void {
store.clearSession();
resetToLanding("replace");
}
function startPurchase(): void {
stopCountdowns();
flow.stopAll();
store.clearCardReturn();
history.replaceState(null, "", "/");
history.pushState(null, "", hashForIndex(1));
panels.length = 0;
showIndex(1, false);
announceLocation();
}
let lastView: PaymentView | null = null;
function isOffline(): boolean {
return navigator.onLine === false;
}
function paint(view: PaymentView): void {
if (view.screen === "cardForm" && cardConfirmPending) {
lastView = view;
return;
}
stopCountdowns();
releaseCardElement();
lastView = view;
syncChrome();
switch (view.screen) {
case "unknownOrder":
root.replaceChildren(screens.unknownOrder(newInvoice));
return;
case "codeIssued":
root.replaceChildren(screens.codeIssued({ code: view.code, savedLocally: view.savedLocally }));
return;
case "paidNoCode":
root.replaceChildren(screens.paidNoCode({ order: view.order, settledAt: view.invoice?.settledAt }));
return;
case "awaitingPayment": {
const awaitingPayment = screens.awaitingPayment({
order: view.order, invoice: view.invoice, method: view.method,
nowMs: Date.now(), resumed: view.resumed, offline: isOffline(),
onCancel: () => cancelInvoiceOnce(view.order.orderId),
...(cancelNotice?.orderId === view.order.orderId && cancelNotice.epoch === flow.epoch
? { notice: cancelNotice.message } : {}),
});
rateHoldStop = awaitingPayment.stop;
root.replaceChildren(awaitingPayment.node);
return;
}
case "awaitingConfirmation":
root.replaceChildren(screens.awaitingConfirmation({
order: view.order, invoice: view.invoice, method: view.method, gaveUp: view.gaveUp,
offline: isOffline() && !view.gaveUp,
onCheckAgain: () => checkAgain(view.order.orderId),
}));
return;
case "windowClosed":
root.replaceChildren(screens.windowClosed({
order: view.order, invoice: view.invoice, offline: isOffline(),
canceled: store.order(view.order.orderId)?.canceled === true,
onNewInvoice: newInvoice,
}));
return;
case "cardForm":
renderCardForm(view);
return;
case "detailsUnavailable":
root.replaceChildren(screens.detailsUnavailable({
order: view.order,
onCheckAgain: () => checkAgain(view.order.orderId), onNewInvoice: newInvoice,
}));
return;
}
}
let cardElementDestroy: (() => void) | null = null;
let cardConfirmPending = false;
function releaseCardElement(): void {
const destroy = cardElementDestroy;
cardElementDestroy = null;
destroy?.();
}
type CardView = Extract<PaymentView, { screen: "cardForm" }>;
// A repaint would build a fresh Element and re-enable Pay while the previous confirm is in flight, and checkout has no idempotency key.
function renderCardForm(view: CardView): void {
releaseCardElement();
const plan = cardPlan(publishableKey(), isOffline());
if (plan.kind === "unavailable") {
root.replaceChildren(cardFailureScreen(view, plan.reason));
return;
}
const shell = (body: HTMLElement): HTMLElement => screens.cardForm({
order: view.order, invoice: view.invoice, resumed: view.resumed, body,
// A PaymentIntent is only cancelable before it confirms, so the button is inert once a confirm is in flight.
onCancel: () => (cardConfirmPending ? Promise.resolve() : cancelInvoiceOnce(view.order.orderId)),
...(cancelNotice?.orderId === view.order.orderId && cancelNotice.epoch === flow.epoch
? { notice: cancelNotice.message } : {}),
});
const mount = screens.cardMount();
let confirm: (() => Promise<ConfirmOutcome>) | null = null;
const fields = screens.cardFields({
mount,
total: screens.invoiceTotal(view.invoice),
onPay: () => {
if (confirm === null) return;
fields.busy(true);
cardConfirmPending = true;
// The confirm redirects the page, so remember the return before calling it.
store.rememberCardReturn(view.order.orderId, Date.now());
void confirm().then((outcome) => {
cardConfirmPending = false;
if (outcome.kind === "submitted") { cardConfirmed(view, node); return; }
store.clearCardReturn();
if (root.firstChild !== node) {
if (lastView !== null) paint(lastView);
return;
}
fields.busy(false);
fields.error(outcome.message);
});
},
});
const node = shell(fields.node);
root.replaceChildren(node);
void mountCard({ plan, clientSecret: view.clientSecret, target: mount, appearance: cardAppearance(), returnUrl: cardReturnUrl(), loadStripe: loadStripeJs })
.then((result) => {
if (root.firstChild !== node) {
if (result.kind === "mounted") result.destroy();
return;
}
if (result.kind === "failed") {
root.replaceChildren(cardFailureScreen(view, result.reason));
return;
}
confirm = result.confirm;
cardElementDestroy = result.destroy;
fields.enable();
});
}
function cardFailureScreen(view: CardView, reason: CardFailure): HTMLElement {
return screens.cardUnavailable({
order: view.order, reason,
onRetry: () => { renderCardForm(view); },
onNewInvoice: newInvoice,
});
}
function cardConfirmed(view: CardView, owner: Node): void {
store.markSubmitted(view.order.orderId);
if (root.firstChild !== owner) return;
const live = flow.liveWatches().find((w) => w.orderId === view.order.orderId)?.restartOptions().record;
const confirmed: OrderRecord = { ...(store.order(view.order.orderId) ?? live ?? view.order), submitted: true };
flow.stopAll();
paint({ screen: "awaitingConfirmation", order: view.order, invoice: undefined, gaveUp: false, method: "card" });
flow.watch(view.order.orderId, { record: confirmed, method: "card" });
}
function goToOrder(orderId: string): void {
history.pushState(null, "", `?order=${encodeURIComponent(orderId)}`);
openOrder(orderId);
announceLocation();
}
function checkAgain(orderId: string): void {
if (flow.checkAgain(orderId) === null) {
if (lastView !== null) paint(lastView);
return;
}
root.replaceChildren(screens.loading());
}
function openOrder(orderId: string): void {
flow.stopAll();
// A connectivity event repaints lastView, which would draw the order just left under this order's URL.
lastView = null;
releaseCardElement();
root.replaceChildren(screens.loading());
flow.watch(orderId, { resumed: true });
}
function showCodes(): void {
flow.stopAll();
stopCountdowns();
// A bare hash resolves against the current URL and keeps the ?order= this was opened from, so push the full path.
if (location.hash !== CODES_HASH) history.pushState(null, "", location.pathname + CODES_HASH);
renderCodes(store.orders());
void flow.refreshHistory().then(renderCodes);
announceLocation();
}
function renderCodes(entries: readonly OrderRecord[]): void {
if (location.hash !== CODES_HASH) return;
lastView = null;
releaseCardElement();
syncChrome();
root.replaceChildren(screens.purchaseHistory({
rows: historyRows(entries),
keepsNewCodes: store.durable,
onOpen: goToOrder,
onStart: startPurchase,
onForget: () => {
if (!window.confirm("Remove every code stored in this browser? This cannot be undone.")) return;
store.forgetEverything();
resetToLanding("replace");
},
}));
}
function syncFromLocation(fresh: boolean): void {
flow.stopAll();
stopCountdowns();
chromeUi.close();
const params = new URLSearchParams(location.search);
const orderId = params.get("order");
if (orderId === null && location.hash === CODES_HASH) {
renderCodes(store.orders());
void flow.refreshHistory().then(renderCodes);
announceLocation();
return;
}
if (orderId === null && fresh) {
const resumeId = store.takeCardReturn(CARD_RETURN_WINDOW_MS, Date.now());
if (resumeId !== undefined) {
history.replaceState(null, "", `?order=${encodeURIComponent(resumeId)}`);
openOrder(resumeId);
announceLocation();
return;
}
}
const load = resolveLoad({ search: location.search }, fresh ? store.newestOpen() : undefined);
if (load.kind === "order") {
openOrder(load.orderId);
announceLocation();
return;
}
const at = reachableIndex(landingIndex());
if (fresh && at > 0) {
// The steps beneath a deep-linked one are not in history, so rebuild the stack or Back leaves the wizard.
history.replaceState(null, "", "/");
for (let i = 1; i <= at; i++) history.pushState(null, "", hashForIndex(i));
} else {
const want = hashForIndex(at);
if (want === "/" ? location.hash !== "" : location.hash !== want) {
history.replaceState(null, "", want === "/" ? location.pathname : want);
}
}
showIndex(at, root.firstChild === track && panels.length > 0);
announceLocation();
}
function applyRoute(hash: string): void {
const path = location.pathname;
history.pushState(null, "", hash === "" || hash === "/" ? path : path + hash);
syncFromLocation(false);
}
window.addEventListener("popstate", () => { syncFromLocation(false); });
window.addEventListener("resize", () => {
if (root.firstChild === track) moveTrack(false);
});
document.addEventListener("visibilitychange", () => {
for (const w of flow.liveWatches()) {
if (document.hidden) w.suspend();
else w.resume();
}
});
function repaintConnectivity(): void {
if (lastView !== null) paint(lastView);
}
window.addEventListener("online", repaintConnectivity);
window.addEventListener("offline", repaintConnectivity);
// Anubis serves its challenge as HTML at the page's path, so a worker registered before the shell is on screen could cache the challenge as the shell.
function registerServiceWorker(): void {
if (!("serviceWorker" in navigator)) return;
void navigator.serviceWorker.register("/sw.js").catch(() => {});
}
// The shell prerenders the welcome page, so the document is that page until the first render, and a load resolving to a codes or order screen never mounts the wizard for measureWelcome to read.
if (embedded && welcomeHeight <= 0) welcomeHeight = Math.ceil(document.documentElement.getBoundingClientRect().height);
syncFromLocation(true);
document.documentElement.classList.remove("sb-booting");
registerServiceWorker();
+232
View File
@@ -0,0 +1,232 @@
import { SINGLE_MONTH } from "./catalog.js";
import { composeOrder, definedOnly } from "./domain.js";
import { screenForOpenOrder } from "./routing.js";
import type { InvoiceView } from "./api.js";
import type { Chain, Method, OrderLocalState, OrderRecord, OrderServerState, OrderStatus } from "./domain.js";
import type { Store } from "./store.js";
export type UnpaidOrder = Omit<OrderRecord, "code">;
// This strips the code by value, since an extra property survives a widening cast.
export function withoutCode(record: OrderRecord): UnpaidOrder {
const { code: _code, ...rest } = record;
return rest;
}
export function withoutDestination(record: OrderRecord): OrderRecord {
return definedOnly({
...record,
address: undefined, cryptoAmount: undefined, cryptoCurrency: undefined, expiresAt: undefined,
});
}
export type PaymentView =
| { screen: "unknownOrder"; orderId: string }
| { screen: "codeIssued"; order: OrderRecord; invoice: InvoiceView | undefined; code: string; savedLocally: boolean }
| { screen: "paidNoCode"; order: UnpaidOrder; invoice: InvoiceView | undefined }
| { screen: "awaitingPayment"; order: UnpaidOrder; invoice: InvoiceView; method: Chain; resumed: boolean }
| { screen: "awaitingConfirmation"; order: UnpaidOrder; invoice: InvoiceView | undefined; gaveUp: boolean; method?: Method }
| { screen: "windowClosed"; order: UnpaidOrder; invoice: InvoiceView | undefined }
| { screen: "cardForm"; order: UnpaidOrder; invoice: InvoiceView; clientSecret: string; resumed: boolean }
| { screen: "detailsUnavailable"; order: UnpaidOrder };
export interface PaymentProgress {
status: OrderStatus;
cryptoAmountPaid?: string | undefined;
paidInFull?: boolean | undefined;
}
export type HistoryRow =
| { kind: "paid"; order: UnpaidOrder; code: string }
| { kind: "paidNoCode"; order: UnpaidOrder }
| { kind: "open"; order: UnpaidOrder }
| { kind: "partPaid"; order: UnpaidOrder }
| { kind: "processing"; order: UnpaidOrder }
| { kind: "canceled"; order: UnpaidOrder }
| { kind: "expired"; order: UnpaidOrder };
export function historyRows(entries: readonly OrderRecord[]): HistoryRow[] {
return entries.map((e) => {
const order = withoutCode(e);
// A canceled order wins over the expired or open status the server left behind, but a paid order
// keeps its code even where a cancel lost to settlement.
switch (orderPhase(e)) {
case "paid":
return e.code !== undefined ? { kind: "paid" as const, order, code: e.code } : { kind: "paidNoCode" as const, order };
case "expired":
return e.canceled === true ? { kind: "canceled" as const, order } : { kind: "expired" as const, order };
case "processing":
return { kind: "processing" as const, order };
case "partPaid":
return { kind: "partPaid" as const, order };
case "awaiting":
return e.canceled === true ? { kind: "canceled" as const, order } : { kind: "open" as const, order };
}
});
}
export function recordFromView(orderId: string, view: InvoiceView, nowMs: number): OrderRecord {
return {
orderId,
badgeType: view.badgeType ?? "",
months: view.months ?? 0,
createdAt: new Date(nowMs).toISOString(),
status: view.status,
};
}
interface Destination {
address: string;
cryptoAmount: string;
cryptoCurrency: Chain;
expiresAt: string | undefined;
}
interface Price {
amount: number;
currency: string;
}
function destinationOf(from: Partial<OrderServerState> | undefined): Destination | undefined {
const { address, cryptoAmount, cryptoCurrency, expiresAt } = from ?? {};
if (address === undefined || cryptoAmount === undefined || cryptoCurrency === undefined) return undefined;
return { address, cryptoAmount, cryptoCurrency, expiresAt };
}
type Paid = Pick<OrderServerState, "amountPaid" | "cryptoAmountPaid" | "cryptoAmountDue" | "paidInFull">;
function paidOf(from: Partial<OrderServerState> | undefined): Paid {
return {
amountPaid: from?.amountPaid,
cryptoAmountPaid: from?.cryptoAmountPaid,
cryptoAmountDue: from?.cryptoAmountDue,
paidInFull: from?.paidInFull,
};
}
function priceOf(from: Partial<OrderServerState> | undefined): Price | undefined {
const { amount, currency } = from ?? {};
return amount !== undefined && currency !== undefined ? { amount, currency } : undefined;
}
function stateOf(status: OrderStatus, price: Price | undefined, paid: Paid, to: Destination | undefined): OrderServerState {
return {
status,
amount: price?.amount,
currency: price?.currency,
...paid,
address: to?.address,
cryptoAmount: to?.cryptoAmount,
cryptoCurrency: to?.cryptoCurrency,
expiresAt: to?.expiresAt,
};
}
// Paid never falls back to the stored value, since the service withdrawing it means it is no longer true.
export function serverState(view: InvoiceView, held: OrderRecord | undefined): OrderServerState {
const fresh = destinationOf(view);
const stored = destinationOf(held);
const to = view.status !== "open" ? undefined
: fresh === undefined ? stored
: { ...fresh, expiresAt: fresh.expiresAt ?? stored?.expiresAt };
return stateOf(view.status, priceOf(view) ?? priceOf(held), paidOf(view), to);
}
function localState(base: OrderRecord, memory: OrderRecord | undefined, method: Method | undefined): OrderLocalState {
return {
code: memory?.code ?? base.code,
submitted: memory?.submitted === true || base.submitted === true ? true : undefined,
canceled: memory?.canceled === true || base.canceled === true ? true : undefined,
method: method ?? base.method,
};
}
export function offlineInvoice(record: OrderRecord): InvoiceView | undefined {
if (record.status !== "open") return undefined;
const to = destinationOf(record);
if (to === undefined) return undefined;
return definedOnly(stateOf("open", priceOf(record), paidOf(record), to));
}
export function closedInvoice(record: OrderRecord): InvoiceView | undefined {
if (record.status === "open") return undefined;
return definedOnly(stateOf(record.status, priceOf(record), paidOf(record), undefined));
}
export function applyView(
store: Store,
orderId: string,
view: InvoiceView,
nowMs: number,
memory?: OrderRecord,
method?: Method,
): { record: OrderRecord; savedLocally: boolean } {
const base = store.order(orderId) ?? memory ?? recordFromView(orderId, view, nowMs);
const record = composeOrder(base, localState(base, memory, method), serverState(view, base));
store.saveOrder(record);
const savedLocally = store.holdsCode(orderId, record.code);
return { record, savedLocally };
}
export function viewFor(
record: OrderRecord,
invoice: InvoiceView | undefined,
method: Method | undefined,
opts: { savedLocally: boolean; resumed: boolean },
): PaymentView {
const order = withoutCode(record);
const resumed = opts.resumed;
const unavailable = { screen: "detailsUnavailable", order } as const;
// The payment is read from the invoice when present, since a held response knows about a payment
// before the record is written from it.
const seen = invoice ?? record;
switch (orderPhase({ status: record.status, cryptoAmountPaid: seen.cryptoAmountPaid, paidInFull: seen.paidInFull })) {
case "paid":
return record.code !== undefined
? { screen: "codeIssued", order: record, invoice, code: record.code, savedLocally: opts.savedLocally }
: { screen: "paidNoCode", order, invoice };
case "expired":
return { screen: "windowClosed", order, invoice };
case "processing":
return method === undefined
? unavailable
: { screen: "awaitingConfirmation", order, invoice, gaveUp: false, method };
case "awaiting":
case "partPaid": {
if (method === undefined) return unavailable;
const screen = screenForOpenOrder(record, method);
if (screen === "awaitingConfirmation") return { screen, order, invoice, gaveUp: false, method };
if (invoice === undefined) return unavailable;
if (screen === "cardForm") {
const clientSecret = invoice.clientSecret;
return clientSecret === undefined ? unavailable : { screen, order, invoice, clientSecret, resumed };
}
if (method === "card" || invoice.address === undefined || invoice.cryptoAmount === undefined) return unavailable;
return { screen: "awaitingPayment", order, invoice, method, resumed };
}
}
}
export type OrderPhase = "awaiting" | "partPaid" | "processing" | "paid" | "expired";
export function orderPhase(o: PaymentProgress): OrderPhase {
if (o.status === "paid") return "paid";
if (o.status === "expired") return "expired";
// Monero reports a payment as confirming while the paid amount is still zero, so the paidInFull
// verdict must be checked before any figure.
if (o.paidInFull === true) return "processing";
return o.cryptoAmountPaid === undefined ? "awaiting" : "partPaid";
}
export function selectionFromOrder(
order: Pick<OrderRecord, "badgeType" | "months"> | undefined,
prices: readonly { priceId: string; badgeType: string }[],
offers: readonly { offerId: string; priceId: string; months: number }[],
): { priceId: string; offerId: string } | undefined {
if (order === undefined) return undefined;
const price = prices.find((p) => p.badgeType === order.badgeType);
if (price === undefined) return undefined;
if (order.months === 1) return { priceId: price.priceId, offerId: SINGLE_MONTH };
const offer = offers.find((o) => o.priceId === price.priceId && o.months === order.months);
return offer === undefined ? undefined : { priceId: price.priceId, offerId: offer.offerId };
}
@@ -0,0 +1,34 @@
export type Reader<T> = (v: unknown) => T | undefined;
// An array passes `typeof v === "object"`, so it is rejected here.
export function asObject(v: unknown): Record<string, unknown> | undefined {
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
export const text: Reader<string> = (v) => (typeof v === "string" ? v : undefined);
export const filledText: Reader<string> = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
export const nonNegativeInteger: Reader<number> = (v) =>
typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : undefined;
export const positiveInteger: Reader<number> = (v) =>
typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
export const flag: Reader<boolean> = (v) => (typeof v === "boolean" ? v : undefined);
export function oneOf<T extends string>(values: readonly T[]): Reader<T> {
return (v) => values.find((member) => member === v);
}
/** A field with the wrong type is rejected rather than treated as "not sent", so a wrong-typed value cannot reach the screen. */
export function fieldsInto<T extends object>(body: Record<string, unknown>, out: Partial<T>) {
return <K extends keyof T & string>(key: K, read: Reader<NonNullable<T[K]>>): boolean => {
const raw = body[key];
if (raw === undefined || raw === null) return true;
const value = read(raw);
if (value === undefined) return false;
out[key] = value;
return true;
};
}
+424
View File
@@ -0,0 +1,424 @@
const MODE_BYTE = 0b0100;
const ECC_LEVEL_M_BITS = 0b00;
const MIN_VERSION = 1;
const MAX_VERSION = 40;
const ECC_PER_BLOCK_M: readonly number[] = [
-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26,
26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
];
const NUM_BLOCKS_M: readonly number[] = [
-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16,
17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49,
];
export interface QrSymbol {
version: number;
size: number;
mask: number;
modules: boolean[][];
}
const GF_PRIMITIVE = 0x11d;
const GF_EXP = new Uint8Array(512);
const GF_LOG = new Uint8Array(256);
{
let x = 1;
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x;
GF_LOG[x] = i;
x <<= 1;
if ((x & 0x100) !== 0) x ^= GF_PRIMITIVE;
}
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255]!;
}
function gfMul(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return GF_EXP[GF_LOG[a]! + GF_LOG[b]!]!;
}
export function generatorPoly(degree: number): number[] {
let poly = [1];
for (let i = 0; i < degree; i++) {
const next = new Array<number>(poly.length + 1).fill(0);
for (let j = 0; j < poly.length; j++) {
next[j] = next[j]! ^ poly[j]!;
next[j + 1] = next[j + 1]! ^ gfMul(poly[j]!, GF_EXP[i]!);
}
poly = next;
}
return poly;
}
export function eccOf(data: readonly number[], eccLen: number): number[] {
const gen = generatorPoly(eccLen);
const rem = new Array<number>(eccLen).fill(0);
for (const byte of data) {
const factor = byte ^ rem[0]!;
rem.shift();
rem.push(0);
for (let i = 0; i < eccLen; i++) rem[i] = rem[i]! ^ gfMul(gen[i + 1]!, factor);
}
return rem;
}
export function rawDataModules(version: number): number {
let bits = (16 * version + 128) * version + 64;
if (version >= 2) {
const numAlign = Math.floor(version / 7) + 2;
bits -= (25 * numAlign - 10) * numAlign - 55;
if (version >= 7) bits -= 36;
}
return bits;
}
function totalCodewords(version: number): number {
return Math.floor(rawDataModules(version) / 8);
}
export function numDataCodewords(version: number): number {
return totalCodewords(version) - ECC_PER_BLOCK_M[version]! * NUM_BLOCKS_M[version]!;
}
function charCountBits(version: number): number {
return version < 10 ? 8 : 16;
}
export function byteCapacity(version: number): number {
return Math.floor((numDataCodewords(version) * 8 - 4 - charCountBits(version)) / 8);
}
function versionFor(byteLength: number): number | null {
for (let version = MIN_VERSION; version <= MAX_VERSION; version++) {
if (byteLength <= byteCapacity(version)) return version;
}
return null;
}
// Version 32 uses a step of 26 because the general spacing rule computes the wrong value for it.
export function alignmentPositions(version: number): number[] {
if (version === 1) return [];
const numAlign = Math.floor(version / 7) + 2;
const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (numAlign * 2 - 2)) * 2;
const positions = [6];
for (let pos = 4 * version + 10; positions.length < numAlign; pos -= step) positions.splice(1, 0, pos);
return positions;
}
export function dataCodewords(bytes: Uint8Array, version: number): number[] {
const capacity = numDataCodewords(version);
const bits: number[] = [];
const push = (value: number, width: number): void => {
for (let i = width - 1; i >= 0; i--) bits.push((value >>> i) & 1);
};
push(MODE_BYTE, 4);
push(bytes.length, charCountBits(version));
for (const byte of bytes) push(byte, 8);
for (let i = 0; i < 4 && bits.length < capacity * 8; i++) bits.push(0);
while (bits.length % 8 !== 0) bits.push(0);
const codewords: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j]!;
codewords.push(byte);
}
for (let pad = 0xec; codewords.length < capacity; pad ^= 0xec ^ 0x11) codewords.push(pad);
return codewords;
}
export function interleave(data: readonly number[], version: number): number[] {
const numBlocks = NUM_BLOCKS_M[version]!;
const eccLen = ECC_PER_BLOCK_M[version]!;
const shortLen = Math.floor(data.length / numBlocks);
const numShort = numBlocks - (data.length % numBlocks);
const blocks: Array<{ data: number[]; ecc: number[] }> = [];
let at = 0;
for (let b = 0; b < numBlocks; b++) {
const len = b < numShort ? shortLen : shortLen + 1;
const block = data.slice(at, at + len);
at += len;
blocks.push({ data: block, ecc: eccOf(block, eccLen) });
}
const out: number[] = [];
for (let i = 0; i <= shortLen; i++) {
for (const block of blocks) if (i < block.data.length) out.push(block.data[i]!);
}
for (let i = 0; i < eccLen; i++) for (const block of blocks) out.push(block.ecc[i]!);
return out;
}
export function formatBits(eclBits: number, mask: number): number {
const data = (eclBits << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
return ((data << 10) | rem) ^ 0x5412;
}
export function versionBits(version: number): number {
let rem = version;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
return (version << 12) | rem;
}
const MASKS: ReadonlyArray<(x: number, y: number) => boolean> = [
(x, y) => (x + y) % 2 === 0,
(_x, y) => y % 2 === 0,
(x, _y) => x % 3 === 0,
(x, y) => (x + y) % 3 === 0,
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
];
const N1 = 3;
const N2 = 3;
const N3 = 40;
const N4 = 10;
class Matrix {
readonly size: number;
readonly modules: boolean[][];
private readonly reserved: boolean[][];
constructor(readonly version: number) {
this.size = version * 4 + 17;
this.modules = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false));
this.reserved = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false));
}
private set(x: number, y: number, dark: boolean): void {
this.modules[y]![x] = dark;
this.reserved[y]![x] = true;
}
isReserved(x: number, y: number): boolean {
return this.reserved[y]![x]!;
}
drawFunctionPatterns(): void {
for (const [cx, cy] of [[3, 3], [this.size - 4, 3], [3, this.size - 4]] as const) {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx;
const y = cy + dy;
if (x < 0 || x >= this.size || y < 0 || y >= this.size) continue;
const ring = Math.max(Math.abs(dx), Math.abs(dy));
this.set(x, y, ring !== 2 && ring <= 3);
}
}
}
for (let i = 0; i < this.size; i++) {
if (!this.reserved[6]![i]!) this.set(i, 6, i % 2 === 0);
if (!this.reserved[i]![6]!) this.set(6, i, i % 2 === 0);
}
const positions = alignmentPositions(this.version);
for (const cy of positions) {
for (const cx of positions) {
if ((cx === 6 && cy === 6) || (cx === 6 && cy === this.size - 7) || (cx === this.size - 7 && cy === 6)) continue;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
this.set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
}
}
for (let i = 0; i < 9; i++) {
if (!this.reserved[i]![8]!) this.set(8, i, false);
if (!this.reserved[8]![i]!) this.set(i, 8, false);
}
for (let i = 0; i < 8; i++) {
this.set(this.size - 1 - i, 8, false);
this.set(8, this.size - 1 - i, false);
}
this.set(8, this.size - 8, true);
if (this.version >= 7) this.drawVersionBlocks();
}
private drawVersionBlocks(): void {
const bits = versionBits(this.version);
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) !== 0;
const a = this.size - 11 + (i % 3);
const b = Math.floor(i / 3);
this.set(a, b, dark);
this.set(b, a, dark);
}
}
drawFormat(mask: number): void {
const bits = formatBits(ECC_LEVEL_M_BITS, mask);
const bit = (i: number): boolean => ((bits >>> i) & 1) !== 0;
for (let i = 0; i <= 5; i++) this.set(8, i, bit(i));
this.set(8, 7, bit(6));
this.set(8, 8, bit(7));
this.set(7, 8, bit(8));
for (let i = 9; i < 15; i++) this.set(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) this.set(this.size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) this.set(8, this.size - 15 + i, bit(i));
this.set(8, this.size - 8, true);
}
drawCodewords(codewords: readonly number[]): void {
let bit = 0;
const total = codewords.length * 8;
for (let right = this.size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < this.size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? this.size - 1 - vert : vert;
if (this.reserved[y]![x]! || bit >= total) continue;
this.modules[y]![x] = ((codewords[bit >>> 3]! >>> (7 - (bit & 7))) & 1) !== 0;
bit++;
}
}
}
}
applyMask(mask: number): void {
const rule = MASKS[mask]!;
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
if (this.reserved[y]![x]!) continue;
if (rule(x, y)) this.modules[y]![x] = !this.modules[y]![x]!;
}
}
}
penalty(): number {
const m = this.modules;
const n = this.size;
let score = 0;
for (let i = 0; i < n; i++) {
let runRow = 1;
let runCol = 1;
for (let j = 1; j < n; j++) {
if (m[i]![j] === m[i]![j - 1]) {
runRow++;
if (runRow === 5) score += N1;
else if (runRow > 5) score += 1;
} else runRow = 1;
if (m[j]![i] === m[j - 1]![i]) {
runCol++;
if (runCol === 5) score += N1;
else if (runCol > 5) score += 1;
} else runCol = 1;
}
}
for (let y = 0; y < n - 1; y++) {
for (let x = 0; x < n - 1; x++) {
const c = m[y]![x]!;
if (c === m[y]![x + 1] && c === m[y + 1]![x] && c === m[y + 1]![x + 1]) score += N2;
}
}
const at = (x: number, y: number): boolean => (x < 0 || y < 0 || x >= n || y >= n ? false : m[y]![x]!);
const RUN = [true, false, true, true, true, false, true, false, false, false, false];
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
let forward = true;
let backward = true;
for (let k = 0; k < RUN.length; k++) {
if (at(x + k, y) !== RUN[k]!) forward = false;
if (at(x + RUN.length - 1 - k, y) !== RUN[k]!) backward = false;
}
if (forward) score += N3;
if (backward) score += N3;
forward = true;
backward = true;
for (let k = 0; k < RUN.length; k++) {
if (at(x, y + k) !== RUN[k]!) forward = false;
if (at(x, y + RUN.length - 1 - k) !== RUN[k]!) backward = false;
}
if (forward) score += N3;
if (backward) score += N3;
}
}
let dark = 0;
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (m[y]![x]!) dark++;
const total = n * n;
score += Math.floor(Math.abs(dark * 20 - total * 10) / total) * N4;
return score;
}
}
export function encodeQr(payload: string): QrSymbol | null {
const bytes = new TextEncoder().encode(payload);
const version = versionFor(bytes.length);
if (version === null) return null;
const matrix = new Matrix(version);
matrix.drawFunctionPatterns();
matrix.drawCodewords(interleave(dataCodewords(bytes, version), version));
let best = -1;
let bestPenalty = Infinity;
for (let mask = 0; mask < MASKS.length; mask++) {
matrix.applyMask(mask);
matrix.drawFormat(mask);
const score = matrix.penalty();
if (score < bestPenalty) {
bestPenalty = score;
best = mask;
}
matrix.applyMask(mask);
}
matrix.applyMask(best);
matrix.drawFormat(best);
return { version, size: matrix.size, mask: best, modules: matrix.modules };
}
export function paymentUri(method: "btc" | "xmr", address: string, amount: string): string | null {
if (address === "" || amount === "") return null;
const scheme = method === "btc" ? "bitcoin" : "monero";
const parameter = method === "btc" ? "amount" : "tx_amount";
// The address is percent-encoded so that a `?`, `&`, or `#` in it cannot add its own parameters to the URI.
return `${scheme}:${encodeURIComponent(address)}?${parameter}=${encodeURIComponent(amount)}`;
}
const SVG_NS = "http://www.w3.org/2000/svg";
const QUIET_ZONE = 4;
function pathOf(symbol: QrSymbol): string {
let d = "";
for (let y = 0; y < symbol.size; y++) {
const row = symbol.modules[y]!;
let x = 0;
while (x < symbol.size) {
if (!row[x]!) {
x++;
continue;
}
let run = 1;
while (x + run < symbol.size && row[x + run]!) run++;
d += `M${x + QUIET_ZONE} ${y + QUIET_ZONE}h${run}v1h-${run}z`;
x += run;
}
}
return d;
}
// SVG elements must be created with createElementNS, because createElement("svg") in an HTML document produces an HTMLUnknownElement that draws nothing.
export function qrSvg(payload: string, label: string): SVGElement | null {
const symbol = encodeQr(payload);
if (symbol === null) return null;
const span = symbol.size + QUIET_ZONE * 2;
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", "qr");
svg.setAttribute("viewBox", `0 0 ${span} ${span}`);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", label);
svg.setAttribute("shape-rendering", "crispEdges");
const background = document.createElementNS(SVG_NS, "rect");
background.setAttribute("width", String(span));
background.setAttribute("height", String(span));
background.setAttribute("fill", "#ffffff");
const dark = document.createElementNS(SVG_NS, "path");
dark.setAttribute("d", pathOf(symbol));
dark.setAttribute("fill", "#000000");
svg.append(background, dark);
return svg;
}
@@ -0,0 +1,19 @@
import type { Method, OrderRecord } from "./domain.js";
export type Screen = "awaitingPayment" | "awaitingConfirmation" | "cardForm";
export type Load = { kind: "order"; orderId: string } | { kind: "step" };
export interface UrlParts { search: string }
export function resolveLoad(url: UrlParts, newestOpen: OrderRecord | undefined): Load {
const orderId = new URLSearchParams(url.search).get("order");
if (orderId) return { kind: "order", orderId };
if (newestOpen) return { kind: "order", orderId: newestOpen.orderId };
return { kind: "step" };
}
export function screenForOpenOrder(order: OrderRecord, method: Method): Screen {
if (method !== "card") return "awaitingPayment";
return order.submitted === true ? "awaitingConfirmation" : "cardForm";
}
File diff suppressed because it is too large. Load diff
+205
View File
@@ -0,0 +1,205 @@
import {
composeOrder, readChain, readMethod, readStatus, readStep, readTheme,
type OrderRecord, type SessionRecord, type Theme,
} from "./domain.js";
import { asObject, filledText, flag, positiveInteger, text, nonNegativeInteger } from "./parse.js";
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
// Date.parse returns NaN for an unreadable value, and NaN breaks the sort comparator, so an unreadable timestamp sorts oldest.
function createdMs(o: OrderRecord): number {
const t = Date.parse(o.createdAt);
return Number.isNaN(t) ? Number.MIN_SAFE_INTEGER : t;
}
const SESSION_KEY = "sb.session.v1";
const ORDERS_KEY = "sb.orders.v1";
const THEME_KEY = "sb.theme.v1";
const CARD_RETURN_KEY = "sb.cardReturn.v1";
const CAP = 50;
function readOrder(value: unknown): OrderRecord | undefined {
const o = asObject(value);
if (o === undefined) return undefined;
const orderId = filledText(o.orderId);
const createdAt = filledText(o.createdAt);
const status = readStatus(o.status);
if (orderId === undefined || createdAt === undefined || status === undefined) return undefined;
return composeOrder(
{
orderId,
createdAt,
badgeType: text(o.badgeType) ?? "",
months: positiveInteger(o.months) ?? 0,
},
{ code: text(o.code), submitted: flag(o.submitted), canceled: flag(o.canceled), method: readMethod(o.method) },
{
status,
amount: positiveInteger(o.amount),
currency: text(o.currency),
amountPaid: nonNegativeInteger(o.amountPaid),
cryptoAmountPaid: text(o.cryptoAmountPaid),
cryptoAmountDue: text(o.cryptoAmountDue),
paidInFull: flag(o.paidInFull),
address: text(o.address),
cryptoAmount: text(o.cryptoAmount),
cryptoCurrency: readChain(o.cryptoCurrency),
expiresAt: text(o.expiresAt),
},
);
}
function readSession(value: unknown): SessionRecord | undefined {
const s = asObject(value);
const step = s === undefined ? undefined : readStep(s.step);
if (s === undefined || step === undefined) return undefined;
const session: SessionRecord = { step };
const priceId = text(s.priceId);
const offerId = text(s.offerId);
const method = readMethod(s.method);
if (priceId !== undefined) session.priceId = priceId;
if (offerId !== undefined) session.offerId = offerId;
if (method !== undefined) session.method = method;
return session;
}
export class Store {
private wipes = 0;
/** `durable` is false for the in-memory fallback used when the browser refuses `localStorage`. */
constructor(private readonly storage: StorageLike, readonly durable = true) {}
canHoldACode(): boolean {
if (!this.durable) return false;
const list = this.orders();
return list.length < CAP || list.some((o) => o.code === undefined);
}
holdsCode(orderId: string, code: string | undefined): boolean {
return this.durable && code !== undefined && this.order(orderId)?.code === code;
}
private read(key: string): unknown {
try {
const raw = this.storage.getItem(key);
return raw === null ? undefined : JSON.parse(raw);
} catch {
return undefined;
}
}
private write(key: string, value: unknown): boolean {
try {
this.storage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
session(): SessionRecord {
return readSession(this.read(SESSION_KEY)) ?? { step: "tier" };
}
saveSession(patch: Partial<SessionRecord>): boolean {
return this.write(SESSION_KEY, { ...this.session(), ...patch });
}
clearSession(): void {
this.forget(SESSION_KEY);
}
orders(): OrderRecord[] {
const list = this.read(ORDERS_KEY);
if (!Array.isArray(list)) return [];
return list.flatMap((o) => readOrder(o) ?? []);
}
// The record replaces the stored entry, but the browser-only fields (code, submitted, canceled) are preserved because the service never sends them back.
saveOrder(record: OrderRecord): boolean {
const list = this.orders();
const at = list.findIndex((o) => o.orderId === record.orderId);
if (at >= 0) {
const kept = list[at]!;
list[at] = {
...record,
...(record.code === undefined && kept.code !== undefined ? { code: kept.code } : {}),
...(kept.submitted === true ? { submitted: true } : {}),
...(kept.canceled === true ? { canceled: true } : {}),
};
} else {
if (list.length >= CAP) {
let victim = -1;
for (let i = list.length - 1; i >= 0; i--) if (!list[i]!.code) { victim = i; break; }
if (victim < 0) return false;
list.splice(victim, 1);
}
list.unshift(record);
}
list.sort((a, b) => createdMs(b) - createdMs(a));
return this.write(ORDERS_KEY, list);
}
markSubmitted(orderId: string): boolean {
const record = this.order(orderId);
if (record === undefined) return false;
return this.saveOrder({ ...record, submitted: true });
}
markCanceled(orderId: string): boolean {
const record = this.order(orderId);
if (record === undefined) return false;
return this.saveOrder({ ...record, canceled: true });
}
order(orderId: string): OrderRecord | undefined {
return this.orders().find((o) => o.orderId === orderId);
}
newestOpen(): OrderRecord | undefined {
return this.orders().find((o) => o.status === "open");
}
theme(): Theme {
return readTheme(this.read(THEME_KEY)) ?? "system";
}
saveTheme(theme: Theme): boolean {
return this.write(THEME_KEY, theme);
}
rememberCardReturn(orderId: string, atMs: number): boolean {
return this.write(CARD_RETURN_KEY, { orderId, at: atMs });
}
takeCardReturn(withinMs: number, nowMs: number): string | undefined {
const held = this.read(CARD_RETURN_KEY);
this.forget(CARD_RETURN_KEY);
if (typeof held !== "object" || held === null) return undefined;
const { orderId, at } = held as { orderId?: unknown; at?: unknown };
if (typeof orderId !== "string" || orderId === "" || typeof at !== "number") return undefined;
return nowMs - at <= withinMs ? orderId : undefined;
}
clearCardReturn(): void {
this.forget(CARD_RETURN_KEY);
}
forgetEverything(): void {
this.wipes += 1;
this.forget(ORDERS_KEY);
this.forget(SESSION_KEY);
}
get wipeCount(): number {
return this.wipes;
}
private forget(key: string): void {
try { this.storage.removeItem(key); } catch { /* a store that refuses this keeps the key */ }
}
}
@@ -0,0 +1,237 @@
import type { Theme } from "./domain.js";
// Stripe.js must load from js.stripe.com and may not be bundled or self-hosted, so this is a script tag.
export const STRIPE_JS_URL = "https://js.stripe.com/dahlia/stripe.js";
const KEY_ELEMENT_ID = "stripe-publishable-key";
declare global {
interface Window {
Stripe?: StripeGlobal;
}
}
export function publishableKey(): string {
return document.getElementById(KEY_ELEMENT_ID)?.getAttribute("content")?.trim() ?? "";
}
let loading: Promise<StripeGlobal> | null = null;
// The tag is built here rather than in `screens`, which imports this module.
export function loadStripeJs(src: string): Promise<StripeGlobal> {
if (loading !== null) return loading;
loading = new Promise<StripeGlobal>((resolve, reject) => {
// The src is set as an attribute because the page's script-src policy is written against the markup.
const tag = document.createElement("script");
tag.setAttribute("src", src);
tag.setAttribute("async", "");
tag.addEventListener("load", () => {
const factory = window.Stripe;
if (factory === undefined) {
loading = null;
reject(new Error("stripe: the script loaded and defined no global"));
return;
}
resolve(factory);
});
tag.addEventListener("error", () => {
loading = null;
// A failed script tag will not load again, so it is removed before a retry.
tag.remove();
reject(new Error("stripe: the script did not load"));
});
document.head.append(tag);
});
return loading;
}
export const CONFIRM_FAILED = "The card was not accepted. Check the details and try again.";
export interface PaymentElement {
mount(target: unknown): void;
destroy(): void;
}
export interface StripeError {
message?: string;
}
export interface StripeElements {
create(kind: "payment"): PaymentElement;
}
export interface PaymentIntentResult {
paymentIntent?: { status?: string };
error?: StripeError;
}
export interface Appearance {
theme: "stripe" | "night" | "flat";
variables?: Record<string, string>;
rules?: Record<string, Record<string, string>>;
}
export interface StripeInstance {
elements(options: { clientSecret: string; appearance?: Appearance }): StripeElements;
confirmPayment(options: {
elements: StripeElements;
confirmParams: { return_url: string };
redirect: "if_required";
}): Promise<PaymentIntentResult>;
}
// Stripe.js runs in its own iframe and cannot read the page's CSS variables, so the palette is
// duplicated here and kept in sync with public/styles.css by hand.
const CARD_FONT =
'Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif';
interface Palette {
accent: string;
onAccent: string;
ink: string;
bg: string;
surface: string;
line: string;
muted: string;
danger: string;
}
const LIGHT_PALETTE: Palette = {
accent: "#0053D0", onAccent: "#FFFFFF", ink: "#0D1B3A", bg: "#F3FAFF", surface: "#FFFFFF", line: "rgba(0, 83, 208, .14)", muted: "rgba(13, 27, 58, .651)", danger: "#b3261e",
};
const DARK_PALETTE: Palette = {
accent: "#70F0F9", onAccent: "#000832", ink: "#EAF2FF", bg: "#000832", surface: "#0B2A59", line: "rgba(112, 240, 249, .18)", muted: "rgba(234, 242, 255, .735)", danger: "#FF9F98",
};
// The flat theme is light-oriented, so dark mode must supply every colour down to the icons and
// spinner, or an unset value keeps the light default and shows as a light patch on a dark form.
function flatAppearance(p: Palette): Appearance {
return {
theme: "flat",
variables: {
colorPrimary: p.accent,
colorBackground: p.surface,
colorText: p.ink,
colorTextSecondary: p.muted,
colorTextPlaceholder: p.muted,
colorDanger: p.danger,
accessibleColorOnColorPrimary: p.onAccent,
iconColor: p.ink,
iconHoverColor: p.ink,
iconCardCvcColor: p.muted,
iconCardCvcErrorColor: p.danger,
iconCardErrorColor: p.danger,
iconChevronDownColor: p.muted,
iconChevronDownHoverColor: p.ink,
iconLoadingIndicatorColor: p.accent,
tabIconColor: p.muted,
tabIconSelectedColor: p.accent,
logoColor: p.ink,
accordionItemLabelColorText: p.ink,
accordionItemLabelSelectedColorText: p.ink,
fontFamily: CARD_FONT,
fontSizeBase: "16px",
borderRadius: "12px",
spacingUnit: "4px",
},
rules: {
".Input": { backgroundColor: p.bg, color: p.ink, border: `1px solid ${p.line}`, boxShadow: "none" },
".Input:focus": { border: `1px solid ${p.accent}`, boxShadow: `0 0 0 1px ${p.accent}` },
".Input--invalid": { border: `1px solid ${p.danger}`, boxShadow: "none" },
".Input::placeholder": { color: p.muted },
".Label": { color: p.ink },
".Tab": { backgroundColor: p.surface, color: p.ink, border: `1px solid ${p.line}`, boxShadow: "none" },
".Tab:hover": { border: `1px solid ${p.line}` },
".Tab--selected": { color: p.ink, border: `1px solid ${p.line}`, boxShadow: "none" },
".AccordionItem": { backgroundColor: p.surface, color: p.ink, border: `1px solid ${p.line}`, boxShadow: "none" },
".AccordionItem--selected": { border: `1px solid ${p.line}`, boxShadow: "none" },
},
};
}
export function appearanceFor(theme: Theme, systemDark: boolean): Appearance {
const dark = theme === "dark" || (theme === "system" && systemDark);
return flatAppearance(dark ? DARK_PALETTE : LIGHT_PALETTE);
}
export type StripeGlobal = (publishableKey: string) => StripeInstance;
export type LoadStripeJs = (src: string) => Promise<StripeGlobal>;
export type CardFailure = "offline" | "script" | "sdk" | "unconfigured";
export type CardPlan =
| { kind: "unavailable"; reason: CardFailure }
| { kind: "load"; publishableKey: string };
export function cardPlan(publishableKey: string | undefined, offline: boolean): CardPlan {
const key = publishableKey?.trim() ?? "";
if (key === "") return { kind: "unavailable", reason: "unconfigured" };
if (offline) return { kind: "unavailable", reason: "offline" };
return { kind: "load", publishableKey: key };
}
export type LoadPlan = Extract<CardPlan, { kind: "load" }>;
export interface MountRequest {
plan: LoadPlan;
clientSecret: string;
target: unknown;
appearance: Appearance;
// The 3DS redirect returns here, so this URL must carry no order id.
returnUrl: string;
loadStripe: LoadStripeJs;
}
export type ConfirmOutcome =
| { kind: "submitted" }
| { kind: "error"; message: string };
export type MountResult =
| {
kind: "mounted";
confirm: () => Promise<ConfirmOutcome>;
destroy: () => void;
}
| { kind: "failed"; reason: CardFailure };
export async function mountCard(req: MountRequest): Promise<MountResult> {
let stripe: StripeGlobal;
try {
stripe = await req.loadStripe(STRIPE_JS_URL);
} catch {
return { kind: "failed", reason: "script" };
}
try {
const sdk = stripe(req.plan.publishableKey);
const elements = sdk.elements({ clientSecret: req.clientSecret, appearance: req.appearance });
const element = elements.create("payment");
element.mount(req.target);
let destroyed = false;
return {
kind: "mounted",
confirm: () => confirmWith(sdk, elements, req.returnUrl),
destroy: () => {
if (destroyed) return;
destroyed = true;
try { element.destroy(); } catch { /* already gone */ }
},
};
} catch {
return { kind: "failed", reason: "sdk" };
}
}
// A card is not a redirect-based method, so redirect:"if_required" keeps it in-frame and resolves
// with the PaymentIntent. A confirm that threw did not succeed and must not be treated as success.
async function confirmWith(sdk: StripeInstance, elements: StripeElements, returnUrl: string): Promise<ConfirmOutcome> {
try {
const r = await sdk.confirmPayment({ elements, confirmParams: { return_url: returnUrl }, redirect: "if_required" });
if (r.error) return { kind: "error", message: r.error.message ?? CONFIRM_FAILED };
const status = r.paymentIntent?.status;
if (status === "succeeded" || status === "processing") return { kind: "submitted" };
return { kind: "error", message: CONFIRM_FAILED };
} catch {
return { kind: "error", message: CONFIRM_FAILED };
}
}
+195
View File
@@ -0,0 +1,195 @@
import { test } from "node:test";
// Installs the globals main.ts needs; it runs once per process on import, so a second boot scenario needs its own file.
import {
MemStorage, StubHistory, installDocument, locationOf,
type Clipboard, type Connectivity, type ServiceWorkers, type StubDocument, type StubElement,
} from "./stub-dom.js";
export interface Reply {
status: number;
body: unknown;
headers?: Record<string, string>;
}
export interface Page {
app: StubElement;
chrome: StubElement;
documentElement: StubElement;
document: StubDocument;
history: StubHistory;
location: { pathname: string; search: string; hash: string };
storage: MemStorage;
clipboard: Clipboard;
fetches: Array<{ url: string; init?: RequestInit }>;
confirms: string[];
workers: ServiceWorkers;
connectivity: Connectivity;
respondWith(reply: Reply): void;
// Answers a request already holding, which respondWith cannot; returns false when nothing matches.
answerHeld(reply: Reply, match?: string): boolean;
confirmAnswer(answer: boolean): void;
setOffline(on: boolean): void;
reducedMotion(on: boolean): void;
fire(type: string): void;
press(key: string, init?: { shiftKey?: boolean }): void;
}
export interface BootOptions {
storage?: MemStorage;
url?: string;
}
export function installPage(opts: BootOptions = {}): Page {
const { app, chrome, documentElement, clipboard, document, workers, connectivity } = installDocument();
const storage = opts.storage ?? new MemStorage();
const fetches: Array<{ url: string; init?: RequestInit }> = [];
const confirms: string[] = [];
const windowListeners = new Map<string, Array<() => void>>();
const location = { pathname: "/", search: "", hash: "" };
let offline = false;
let nextResponse: Reply | null = null;
const held: Array<{ url: string; resolve: (r: Response) => void }> = [];
let answer = true;
let reduced = false;
const syncLocation = (): void => { Object.assign(location, locationOf(history.url)); };
const fire = (type: string): void => {
syncLocation();
for (const fn of [...(windowListeners.get(type) ?? [])]) fn();
};
const press = (key: string, init: { shiftKey?: boolean } = {}): void => {
const event = { key, shiftKey: false, ...init, preventDefault: () => {} };
for (const fn of [...(windowListeners.get("keydown") ?? [])]) (fn as (e: unknown) => void)(event);
};
const history = new StubHistory(() => { fire("popstate"); }, () => { syncLocation(); });
if (opts.url !== undefined) {
history.replaceState(null, "", opts.url);
}
syncLocation();
Object.defineProperty(globalThis, "history", { configurable: true, value: history });
Object.defineProperty(globalThis, "location", { configurable: true, value: location });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
localStorage: storage,
confirm: (message: string) => { confirms.push(message); return answer; },
matchMedia: (query: string) => ({
media: query,
matches: query === "(prefers-reduced-motion: reduce)" && reduced,
}),
addEventListener(type: string, fn: () => void) {
const list = windowListeners.get(type) ?? [];
list.push(fn);
windowListeners.set(type, list);
},
fetch: async (input: unknown, init?: RequestInit): Promise<Response> => {
fetches.push(init ? { url: String(input), init } : { url: String(input) });
// A browser with no network throws this TypeError.
if (offline) throw new TypeError("Failed to fetch");
const reply = nextResponse;
nextResponse = null;
if (reply === null) {
// A pending promise with no timer lets the process still exit while the request holds.
return new Promise<Response>((resolve, reject) => {
const signal = init?.signal;
// abort never fires for an already-aborted signal, so reject explicitly or the stub holds where fetch would reject.
if (signal?.aborted) { queueMicrotask(() => { reject(new Error("aborted")); }); return; }
const entry = { url: String(input), resolve };
held.push(entry);
signal?.addEventListener("abort", () => {
const at = held.indexOf(entry);
if (at >= 0) held.splice(at, 1);
// A real fetch rejects an aborted request on the microtask drain of the same turn.
queueMicrotask(() => { reject(new Error("aborted")); });
}, { once: true });
});
}
return responseOf(reply);
},
},
});
function responseOf(reply: Reply): Response {
return {
ok: reply.status < 400,
status: reply.status,
headers: {
get: (name: string) => {
const map = reply.headers ?? {};
const key = Object.keys(map).find((k) => k.toLowerCase() === name.toLowerCase());
return key === undefined ? null : map[key]!;
},
},
json: async () => reply.body,
text: async () => JSON.stringify(reply.body),
} as unknown as Response;
}
return {
app, chrome, documentElement, document, history, location, storage, clipboard, fetches, confirms, workers, connectivity,
respondWith: (reply) => { nextResponse = reply; },
answerHeld: (reply, match) => {
const at = match === undefined ? 0 : held.findIndex((h) => h.url.includes(match));
if (at < 0 || held.length === 0) return false;
const [entry] = held.splice(at, 1);
entry!.resolve(responseOf(reply));
return true;
},
confirmAnswer: (value) => { answer = value; },
setOffline: (on) => {
offline = on;
connectivity.online = !on;
fire(on ? "offline" : "online");
},
reducedMotion: (on) => { reduced = on; },
fire,
press,
};
}
export const flush = (): Promise<void> => new Promise((r) => setImmediate(r));
export async function settle(times = 6): Promise<void> {
for (let i = 0; i < times; i++) await flush();
}
// Waits for an outcome rather than a fixed number of turns, since crypto.subtle.digest resolves off the main thread and a tick count would race.
export async function until(condition: () => boolean, what: string, turns = 500): Promise<void> {
for (let i = 0; i < turns; i++) {
if (condition()) return;
await flush();
}
throw new Error(`timed out waiting for ${what}`);
}
export function timedTest(ms: number) {
return (name: string, fn: () => void | Promise<void>): void => {
test(name, { timeout: ms }, fn);
};
}
export function screenOf(app: StubElement): StubElement { return app.all("section.panel")[0]!; }
export function inViewOf(app: StubElement): StubElement {
const found = app.all("section.panel").find((p) => !p.hasAttribute("inert"));
if (found === undefined) throw new Error("exactly one panel must be in view");
return found;
}
export function headingOf(p: StubElement): string { return p.all("h1")[0]?.textContent ?? ""; }
export function primaryOf(p: StubElement): StubElement | undefined {
return p.all("button.primary").find((b) => !b.hasAttribute("disabled"));
}
const FORGET_LABEL = "Forget everything on this device";
export function forgetControl(page: Page): StubElement | undefined {
page.chrome.all("button.menu-button")[0]!.click();
page.chrome.all("button.menu-item").find((b) => b.textContent === "Your codes")!.click();
return page.app.all("button.danger").find((b) => b.textContent === FORGET_LABEL);
}
@@ -0,0 +1,117 @@
import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
const buildTest = timedTest(2000);
interface BuildModule {
HASH_CHARS: number;
ASSET_PATTERN: RegExp;
BUILD_PATTERN: RegExp;
served(source: string): string;
hashOf(files: Array<readonly [string, string | Buffer]>): string;
assets(compiled?: string, stylesheet?: string, images?: string): Array<[string, string | Buffer]>;
retarget(text: string, pattern: RegExp, replacement: string, what: string): string;
withBuild(html: string, build: string): string;
withBuildId(js: string, build: string): string;
}
// A non-literal specifier keeps tsc from resolving build.js, which this project does not compile.
const build = (await import(new URL("../../build.js", import.meta.url).href)) as BuildModule;
const indexHtml = readFileSync(new URL("../../public/index.html", import.meta.url), "utf8");
const workerJs = readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8");
function declaredIn(text: string): string[] {
return [...text.matchAll(/[0-9a-f]{16}/g)].map((m) => m[0]);
}
// Fails when the committed shell or worker names a build other than the one on disk, forcing a rebuild before commit.
buildTest("build: the committed shell and worker name the build that is on disk", () => {
const current = build.hashOf(build.assets());
assert.equal(new Set(declaredIn(indexHtml)).size, 1, "the shell names exactly one build");
assert.equal(declaredIn(indexHtml)[0], current,
"public/index.html names a build that is not this one — run `npm run build` and commit the result");
assert.ok(workerJs.includes(`const BUILD = "${current}";`),
"public/sw.js names a build that is not this one — run `npm run build` and commit the result");
});
buildTest("build: the hash is derived from the bytes that are served, and from nothing else", () => {
const files: Array<readonly [string, string]> = [["main.js", "a"], ["styles.css", "b"]];
assert.equal(build.hashOf(files), build.hashOf(files), "same content, same hash — twice in a row");
assert.equal(build.hashOf(files), build.hashOf([...files].reverse()), "and independent of the order read");
assert.notEqual(build.hashOf(files), build.hashOf([["main.js", "a "], ["styles.css", "b"]]),
"one byte of one module changes it: this is what stops a cache-first page serving last week's code");
assert.notEqual(build.hashOf(files), build.hashOf([["main.js", "a"], ["styles.css", "b "]]),
"the stylesheet is served under the same hash, so it counts too");
assert.notEqual(build.hashOf(files), build.hashOf([["other.js", "a"], ["styles.css", "b"]]),
"and so does the name each is served under");
assert.match(build.hashOf(files), new RegExp(`^[0-9a-f]{${build.HASH_CHARS}}$`));
});
buildTest("build: a rebuild of unchanged sources produces the same hash and the same files", () => {
const first = build.assets();
const second = build.assets();
assert.deepEqual(first, second);
assert.equal(build.hashOf(first), build.hashOf(second), "idempotent: nothing changed, nothing moves");
});
buildTest("build: what is hashed is what is served — every module, the stylesheet, the images, the fonts, no maps", () => {
const names = build.assets().map(([name]) => name).sort();
const modules = readdirSync(new URL("../../src", import.meta.url))
.filter((f) => f.endsWith(".ts")).map((f) => f.replace(/\.ts$/, ".js"));
const images = readdirSync(new URL("../../public/img", import.meta.url))
.filter((f) => f.endsWith(".png") || f.endsWith(".svg"));
const fonts = readdirSync(new URL("../../public/fonts", import.meta.url)).filter((f) => f.endsWith(".woff2"));
assert.ok(images.some((f) => f.endsWith(".png")), "the hero has to be somewhere for the stylesheet to point at");
assert.ok(images.some((f) => f.endsWith(".svg")), "and so does the wordmark");
assert.ok(fonts.length > 0, "and the faces the stylesheet declares");
assert.deepEqual(names, [...modules, "styles.css", "init.js", ...images, ...fonts].sort());
assert.ok(!names.some((n) => n.endsWith(".map")),
"a source map under the hash would 404 on its sources, which are not served");
});
buildTest("build: an image's BYTES move the hash, so a redrawn hero is a new build", () => {
// Read as bytes, since a PNG decoded as UTF-8 is a lossy string two different images can collide on.
const [, content] = build.assets().find(([name]) => name.endsWith(".png"))!;
assert.ok(Buffer.isBuffer(content), "images are hashed and written as bytes");
const base: Array<readonly [string, string | Buffer]> = [["styles.css", "a"], ["hero-light.png", content]];
const flipped = Buffer.from(content);
const last = flipped.length - 1;
flipped[last] = flipped[last]! ^ 0xff;
assert.notEqual(build.hashOf(base), build.hashOf([["styles.css", "a"], ["hero-light.png", flipped]]),
"one byte of the hero is a different build");
});
buildTest("build: the sourceMappingURL comment is stripped, because the map is not published", () => {
assert.equal(build.served("const a = 1;\n//# sourceMappingURL=a.js.map\n"), "const a = 1;\n");
assert.equal(build.served("const a = 1;\n"), "const a = 1;\n", "a module without one is untouched");
for (const [, content] of build.assets()) {
assert.ok(!content.includes("sourceMappingURL"), "no served file may point at a map");
}
});
buildTest("build: both files are retargeted in one pass, and each really changes", () => {
const to = "0123456789abcdef";
const html = build.withBuild(indexHtml, to);
assert.deepEqual([...new Set(declaredIn(html))], [to], "every asset path in the shell moves together");
assert.ok(html.includes(`/assets/${to}/main.js`) && html.includes(`/assets/${to}/styles.css`));
const js = build.withBuildId(workerJs, to);
assert.ok(js.includes(`const BUILD = "${to}";`));
assert.ok(!js.includes(declaredIn(workerJs)[0]!), "the old build id is gone from the worker");
});
buildTest("build: retargeting is idempotent", () => {
const to = "0123456789abcdef";
assert.equal(build.withBuild(build.withBuild(indexHtml, to), to), build.withBuild(indexHtml, to));
assert.equal(build.withBuildId(build.withBuildId(workerJs, to), to), build.withBuildId(workerJs, to));
});
buildTest("build: retargeting nothing FAILS rather than quietly shipping the old hash", () => {
assert.throws(() => build.withBuild('<script src="/js/main.js"></script>', "0123456789abcdef"),
/no asset path to rewrite/);
assert.throws(() => build.withBuildId('const VERSION = "1";', "0123456789abcdef"),
/no BUILD constant to rewrite/);
assert.doesNotThrow(() => build.withBuild(indexHtml, "0123456789abcdef"));
assert.doesNotThrow(() => build.withBuildId(workerJs, "0123456789abcdef"));
});
@@ -0,0 +1,65 @@
import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
import { CATALOG, offerTotal, savingPercent } from "../src/catalog.js";
const catalogTest = timedTest(2000);
catalogTest("catalog: one month at the month price", () => {
assert.deepEqual(offerTotal(7000, undefined), { months: 1, price: 7000, amount: 7000 });
});
catalogTest("catalog: free months charge for the difference and deliver the term", () => {
const offer = { offerId: "o", priceId: "p", months: 3, discount: { type: "freeMonths", freeMonths: 1 } } as const;
assert.deepEqual(offerTotal(7000, offer), { months: 3, price: 21000, amount: 14000 });
});
catalogTest("catalog: percentage discount truncates in the buyer's favour", () => {
const offer = { offerId: "o", priceId: "p", months: 3, discount: { type: "discount", discount: 33 } } as const;
// 21000 * 67 / 100 = 14070
assert.deepEqual(offerTotal(7000, offer), { months: 3, price: 21000, amount: 14070 });
});
catalogTest("catalog: every guard returns a reason, never a number", () => {
const bad = [
{ offerId: "a", priceId: "p", months: 0, discount: { type: "discount", discount: 10 } },
{ offerId: "b", priceId: "p", months: 3, discount: { type: "freeMonths", freeMonths: 3 } },
{ offerId: "c", priceId: "p", months: 3, discount: { type: "discount", discount: 100 } },
] as const;
for (const o of bad) assert.equal(typeof offerTotal(7000, o), "string", `${o.offerId} must be refused`);
assert.equal(typeof offerTotal(0, undefined), "string", "a zero month price is unsellable");
});
catalogTest("catalog: the compiled-in catalog matches the screens", () => {
const s = CATALOG.prices.find((p) => p.badgeType === "supporter");
const l = CATALOG.prices.find((p) => p.badgeType === "legend");
assert.equal(s?.monthPrice, 700);
assert.equal(l?.monthPrice, 7000);
});
catalogTest("catalog: the saving percentage is display-only arithmetic", () => {
assert.equal(savingPercent(21000, 14000), 33);
assert.equal(savingPercent(7000, 7000), 0);
});
catalogTest("catalog: guards reject fractional months", () => {
const offer = { offerId: "o", priceId: "p", months: 2.5, discount: { type: "freeMonths", freeMonths: 1 } } as const;
assert.equal(typeof offerTotal(700, offer), "string", "fractional months must be rejected");
});
catalogTest("catalog: guards reject negative free months", () => {
const offer = { offerId: "o", priceId: "p", months: 1, discount: { type: "freeMonths", freeMonths: -5 } } as const;
assert.equal(typeof offerTotal(700, offer), "string", "negative free months must be rejected");
});
catalogTest("catalog: guards reject negative discount", () => {
const offer = { offerId: "o", priceId: "p", months: 1, discount: { type: "discount", discount: -50 } } as const;
assert.equal(typeof offerTotal(700, offer), "string", "negative discount must be rejected");
});
catalogTest("catalog: a full price over the cap is unsellable, as the service also refuses it", () => {
const offer = { offerId: "o", priceId: "p", months: 3, discount: { type: "freeMonths", freeMonths: 2 } } as const;
assert.equal(typeof offerTotal(50_000_000, offer), "string", "gross 150000000 is over the cap");
const ok = offerTotal(3500, { offerId: "o", priceId: "p", months: 12, discount: { type: "freeMonths", freeMonths: 2 } });
assert.deepEqual(ok, { months: 12, price: 42000, amount: 35000 }, "an ordinary offer still prices");
});
@@ -0,0 +1,140 @@
import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
import { ALPHABET, PAYLOAD, canonical, checkChar, display, normalise, generate, hash } from "../src/codes.js";
const codeTest = timedTest(5000);
// The golden vector's canonical form and hash come from parseBadgeCode; a divergence would sell codes the service cannot redeem.
const VECTOR_BODY = "4RT6E8YBMW74Q8DK9DKR";
const VECTOR = "SB-4RT6E-8YBMW-74Q8D-K9DKR";
const VECTOR_CANONICAL = "SB4RT6E8YBMW74Q8DK9DKR";
const VECTOR_HASH = "Lyr52PVy843AXApBOwdq8hJKCfkpE4zuR_Xm_50SDQg";
codeTest("codes: the vector agrees with parseBadgeCode's canonical form and hash", async () => {
assert.equal(checkChar(VECTOR_BODY.slice(0, 19)), VECTOR_BODY[19]);
assert.equal(display(VECTOR_BODY), VECTOR);
assert.equal(canonical(VECTOR_BODY), VECTOR_CANONICAL);
assert.equal(await hash(VECTOR_BODY), VECTOR_HASH);
});
codeTest("codes: alphabet is Crockford base32", () => {
assert.equal(ALPHABET, "0123456789ABCDEFGHJKMNPQRSTVWXYZ");
assert.equal(ALPHABET.length, 32);
for (const bad of "ILOU") assert.ok(!ALPHABET.includes(bad), `${bad} must not be in the alphabet`);
});
function randomBody(): string {
let s = "";
for (let i = 0; i < 19; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]!;
return s;
}
codeTest("codes: every single-character substitution is detected", () => {
let undetected = 0;
for (let n = 0; n < 1000; n++) {
const body = randomBody();
const code = body + checkChar(body);
for (let i = 0; i < 20; i++) {
for (const c of ALPHABET) {
if (c === code[i]) continue;
const g = code.slice(0, i) + c + code.slice(i + 1);
if (checkChar(g.slice(0, 19)) === g[19]) undetected++;
}
}
}
assert.equal(undetected, 0);
});
// Luhn mod N detects adjacent transpositions but not a swap of 0 and Z, which this test allows for.
codeTest("codes: every adjacent transposition is detected but Luhn's 0/Z blind spot", () => {
let undetected = 0;
let blindSpot = 0;
for (let n = 0; n < 2000; n++) {
const body = randomBody();
const code = body + checkChar(body);
for (let i = 0; i < 18; i++) {
const j = i + 1;
if (code[i] === code[j]) continue;
const a = code.split("");
[a[i], a[j]] = [a[j]!, a[i]!];
const g = a.join("");
if (checkChar(g.slice(0, 19)) !== g[19]) continue;
if ([code[i], code[j]].sort().join("") === "0Z") blindSpot++;
else undetected++;
}
}
assert.equal(undetected, 0);
assert.ok(blindSpot > 0, "the 0/Z pair should have turned up in two thousand codes");
});
codeTest("codes: normalise folds I, L and O, and requires the prefix", () => {
assert.equal(normalise("sb-4rt6e-8ybmw-74q8d-k9dkr"), VECTOR_BODY);
assert.equal(normalise(" SB 4RT6E 8YBMW 74Q8D K9DKR "), VECTOR_BODY);
const folded = normalise(display("1".repeat(19) + checkChar("1".repeat(19))).replace(/1/g, "I"));
assert.equal(folded, "1".repeat(19) + checkChar("1".repeat(19)));
assert.equal(normalise("SB-UUUUU-UUUUU-UUUUU-UUUUU"), null);
assert.equal(normalise("SB-TOOSHORT"), null);
assert.equal(normalise("4RT6E8YBMW74Q8DK9DKR"), null);
assert.equal(normalise(display(VECTOR_BODY.slice(0, 19) + (VECTOR_BODY[19] === "0" ? "1" : "0"))), null);
});
codeTest("codes: generate produces a valid code", () => {
for (let n = 0; n < 200; n++) {
const c = generate();
assert.equal(c.length, 20);
for (const ch of c) assert.ok(ALPHABET.includes(ch));
assert.equal(checkChar(c.slice(0, 19)), c[19]);
assert.equal(normalise(display(c)), c);
}
});
codeTest("codes: hash is base64url sha-256 over the canonical form, prefix included", async () => {
const h = await hash(VECTOR_BODY);
assert.match(h, /^[A-Za-z0-9_-]{43}$/);
assert.equal(h, await hash(normalise(VECTOR)!));
const bytes = new TextEncoder().encode(VECTOR_BODY);
const bare = await crypto.subtle.digest("SHA-256", bytes);
const bareB64 = btoa(String.fromCharCode(...new Uint8Array(bare)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
assert.notEqual(h, bareB64);
});
codeTest("codes: every code drawn is a different one, and the draw covers the alphabet", () => {
const drawn = new Set<string>();
const symbols = new Set<string>();
const perPosition = Array.from({ length: PAYLOAD }, () => new Set<string>());
for (let i = 0; i < 5000; i++) {
const code = generate();
drawn.add(code);
for (const c of code) symbols.add(c);
for (let at = 0; at < PAYLOAD; at++) perPosition[at]!.add(code[at]!);
}
assert.equal(drawn.size, 5000, "two buyers must never be handed the same code");
assert.equal(symbols.size, ALPHABET.length, `the draw reached ${symbols.size} of ${ALPHABET.length} symbols`);
for (const [at, seen] of perPosition.entries()) {
assert.equal(seen.size, ALPHABET.length,
`payload position ${at} drew ${seen.size} of ${ALPHABET.length} symbols, so the alphabet is narrowed`);
}
});
codeTest("codes: stripping is Unicode, the way parseBadgeCode's isAlphaNum is", () => {
// An ASCII-only strip would drop an Arabic-Indic digit and read the rest as a valid code the service refuses.
assert.equal(normalise("SB\u0663-4RT6E-8YBMW-74Q8D-K9DKR"), null);
assert.equal(normalise("SB-4RT6E-8YBMW-74Q8D-K9DKR"), VECTOR_BODY, "and the separators still go");
});
codeTest("codes: the payload comes from the CSPRNG, one byte per character", () => {
const real = globalThis.crypto.getRandomValues.bind(globalThis.crypto);
const asked: number[] = [];
globalThis.crypto.getRandomValues = ((buf: ArrayBufferView) => {
asked.push(buf.byteLength);
return real(buf as Uint8Array<ArrayBuffer>);
}) as typeof globalThis.crypto.getRandomValues;
try {
const code = generate();
assert.equal(code.length, PAYLOAD + 1, "19 drawn characters and the check character");
assert.deepEqual(asked, [PAYLOAD], "one draw from the CSPRNG, of one byte per payload character");
} finally {
globalThis.crypto.getRandomValues = real;
}
});
@@ -0,0 +1,59 @@
import { readFileSync } from "node:fs";
export interface Rule { selector: string; decls: Map<string, string> }
interface Sheet { rules: Rule[]; media: Array<{ query: string; rules: Rule[] }> }
function declarations(body: string): Map<string, string> {
const out = new Map<string, string>();
for (const part of body.split(";")) {
const at = part.indexOf(":");
if (at < 0) continue;
out.set(part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim());
}
return out;
}
// Brace-matching with comments stripped; handles one level of @media.
function parseCss(source: string): Sheet {
const text = source.replace(/\/\*[\s\S]*?\*\//g, "");
const sheet: Sheet = { rules: [], media: [] };
let i = 0;
while (i < text.length) {
const open = text.indexOf("{", i);
if (open < 0) break;
const prelude = text.slice(i, open).trim();
let depth = 1;
let j = open + 1;
while (j < text.length && depth > 0) {
if (text[j] === "{") depth++;
else if (text[j] === "}") depth--;
j++;
}
const body = text.slice(open + 1, j - 1);
if (prelude.startsWith("@media")) {
sheet.media.push({ query: prelude.slice("@media".length).trim(), rules: parseCss(body).rules });
} else if (!prelude.startsWith("@")) {
for (const selector of prelude.split(",")) sheet.rules.push({ selector: selector.trim(), decls: declarations(body) });
}
i = j;
}
return sheet;
}
export const sheet = parseCss(readFileSync(new URL("../../public/styles.css", import.meta.url), "utf8"));
export const ruleFor = (rules: Rule[], selector: string): Rule | undefined => rules.find((r) => r.selector === selector);
export const mediaFor = (query: string): { query: string; rules: Rule[] } | undefined =>
sheet.media.find((m) => m.query.replace(/\s+/g, "") === query.replace(/\s+/g, ""));
// The value the browser uses is the last declaration for the selector in the sheet, not the first.
export function decl(selector: string, property: string): string | undefined {
let value: string | undefined;
for (const rule of sheet.rules) {
if (rule.selector !== selector) continue;
const found = rule.decls.get(property);
if (found !== undefined) value = found;
}
return value;
}
@@ -0,0 +1,691 @@
import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { decl, mediaFor, type Rule, ruleFor, sheet } from "./css.js";
import * as stripe from "../src/stripe.js";
const designTest = timedTest(2000);
import { StubElement, installDocument } from "./stub-dom.js";
installDocument();
const icons = await import("../src/icons.js");
const screens = await import("../src/screens.js");
function render(node: unknown): StubElement { return node as unknown as StubElement; }
// The website's palette, as its blog pages use it: website/tailwind.config.js (primary-light,
// primary-hover-light, primary-pressed-light, secondary-bg-light; primary-dark and its steps,
// primary-bg-dark, card-bg-dark, secondary-bg-dark) and website/src/css/blog.css (the heading
// gradients and the table rules); the two text colours are simplex.domains' ink and ink-soft.
const REFERENCE_LIGHT: ReadonlyArray<readonly [string, string]> = [
["--accent", "#0053D0"],
["--accent-hover", "#1661D1"],
["--accent-pressed", "#004ABB"],
["--on-accent", "#FFFFFF"],
["--ink", "#0D1B3A"],
["--bg", "#F3FAFF"],
["--surface", "#FFFFFF"],
["--menu", "#FFFFFF"],
["--line", "rgba(0, 83, 208, .14)"],
["--ink-soft", "rgba(13, 27, 58, .651)"],
["--title", "linear-gradient(to bottom, #53C1FF -50%, #0053D0 160%)"],
];
const REFERENCE_DARK: ReadonlyArray<readonly [string, string]> = [
["--accent", "#70F0F9"],
["--accent-hover", "#66D9E2"],
["--accent-pressed", "#5CD3DC"],
["--on-accent", "#000832"],
["--ink", "#EAF2FF"],
["--bg", "#000832"],
["--surface", "#0B2A59"],
["--menu", "#0B2A59"],
["--line", "rgba(112, 240, 249, .18)"],
["--ink-soft", "rgba(234, 242, 255, .735)"],
["--title", "linear-gradient(to bottom, #70F0F9 0%, #70F0F9 33%, #50D8F1 100%)"],
];
const SEMANTIC: ReadonlyArray<readonly [string, string]> = [
["--ok-fg", "#1c7c3f"],
["--ok-bg", "#e8f5ec"],
["--ok-line", "#bfe3cc"],
["--warn-fg", "#8a6100"],
["--warn-bg", "#fff8e6"],
["--warn-line", "#f0dcae"],
["--danger-fg", "#b3261e"],
["--danger-bg", "#fdecec"],
["--danger-line", "#f3c9c6"],
];
const PALETTE: ReadonlyArray<readonly [string, string]> = [...REFERENCE_LIGHT, ...SEMANTIC];
const darkQuery = (): Rule => {
const media = mediaFor("(prefers-color-scheme: dark)");
assert.ok(media, "the dark query must exist");
const rule = ruleFor(media.rules, ':root:not([data-theme="light"])');
assert.ok(rule, `the dark query must claim everything but an explicit light, and got ${JSON.stringify(media.rules.map((r) => r.selector))}`);
return rule;
};
const darkAttribute = (): Rule => {
const rule = ruleFor(sheet.rules, ':root[data-theme="dark"]');
assert.ok(rule, "an explicit dark choice must beat an operating system set to light");
return rule;
};
designTest("design: every light token holds the value it is measured from", () => {
const root = ruleFor(sheet.rules, ":root");
assert.ok(root, "the palette must be defined on bare :root, not only inside a media query");
for (const [token, value] of PALETTE) {
const found = root.decls.get(token);
assert.ok(found !== undefined, `${token} is not defined on :root`);
assert.equal(found.toLowerCase(), value.toLowerCase(), `${token} must be ${value}`);
}
});
designTest("design: the Stripe card form's palette is the site's own tokens, not a second copy", () => {
// stripe.ts hard-codes the palette because Stripe.js runs in a cross-origin iframe that cannot read our CSS variables.
const light = ruleFor(sheet.rules, ":root")!;
const dark = darkAttribute();
const tok = (rule: Rule, name: string): string => (rule.decls.get(name) ?? light.decls.get(name) ?? "").toLowerCase();
const norm = (s: string | undefined): string => (s ?? "").trim().toLowerCase();
for (const [label, t, rule] of [["light", "light", light], ["dark", "dark", dark]] as const) {
const a = stripe.appearanceFor(t, false);
assert.equal(a.theme, "flat", `${label} uses the flat theme`);
const v = a.variables!, r = a.rules!;
assert.equal(norm(v.colorPrimary), tok(rule, "--accent"), `${label} colorPrimary is --accent`);
assert.equal(norm(v.colorBackground), tok(rule, "--surface"), `${label} colorBackground is --surface`);
assert.equal(norm(v.colorText), tok(rule, "--ink"), `${label} colorText is --ink`);
assert.equal(norm(v.colorTextSecondary), tok(rule, "--ink-soft"), `${label} colorTextSecondary is --ink-soft`);
assert.equal(norm(v.colorDanger), tok(rule, "--danger-fg"), `${label} colorDanger is --danger-fg`);
assert.equal(norm(r[".Input"]?.backgroundColor), tok(rule, "--bg"), `${label} .Input fill is --bg`);
assert.ok(norm(r[".Input"]?.border).includes(tok(rule, "--line")), `${label} .Input border is --line`);
assert.ok(norm(r[".Input:focus"]?.border).includes(tok(rule, "--accent")), `${label} .Input:focus is --accent`);
}
});
designTest("design: the dark theme is the website's own, down to the ground the dark button's label takes", () => {
const light = ruleFor(sheet.rules, ":root")!;
const dark = darkQuery();
for (const [token, value] of REFERENCE_DARK) {
assert.equal(dark.decls.get(token)?.toLowerCase(), value.toLowerCase(), `${token} must be ${value} under dark`);
}
assert.equal(dark.decls.get("--on-accent")?.toLowerCase(), dark.decls.get("--bg")?.toLowerCase(),
"the label on the dark accent is the page's ground, as the site's dark buttons are drawn");
for (const [label, rule] of [["light", light], ["dark", dark]] as const) {
assert.match(rule.decls.get("--page") ?? "", /^radial-gradient\(50% 50% at 50% 50%,/,
`${label} has one faint centred wash of its accent over the ground`);
}
assert.notEqual(dark.decls.get("--page"), light.decls.get("--page"), "each wash is in its own theme's accent");
assert.equal(decl("body", "background-image"), "var(--page)");
});
designTest("design: the two dark blocks are the same palette, token for token", () => {
const fromQuery = darkQuery();
const fromAttribute = darkAttribute();
assert.deepEqual([...fromAttribute.decls.entries()].sort(), [...fromQuery.decls.entries()].sort());
assert.ok(fromQuery.decls.size >= REFERENCE_DARK.length + SEMANTIC.length);
});
designTest("design: dark restates every token, and none of them by the light value", () => {
const light = ruleFor(sheet.rules, ":root")!;
const dark = darkQuery();
for (const [token] of PALETTE) {
const value = dark.decls.get(token);
assert.ok(value !== undefined, `${token} is not redefined under dark`);
assert.notEqual(value.toLowerCase(), light.decls.get(token)!.toLowerCase(),
`${token} is the same in both themes, which is one of them being wrong`);
}
for (const token of ["--hero", "--wordmark"]) {
assert.notEqual(dark.decls.get(token), light.decls.get(token),
`${token} is a second image, not the first one dimmed`);
}
});
designTest("design: the content column is 640px, and the gutter is outside it", () => {
assert.equal(decl("#app", "max-width"), "640px");
assert.equal(decl("#app", "margin"), "0 auto", "and it is centred");
assert.equal(decl("footer", "max-width"), "640px", "the footer rule spans the same column");
assert.match(decl("body", "padding") ?? "", /^0 \d+px$/, "the gutter is the sides, and nothing on top");
const panelPadding = decl(".panel", "padding") ?? "";
assert.ok(/^\d+px 0( \d+px)?$/.test(panelPadding),
`a panel adds no horizontal padding, and got "${panelPadding}"`);
});
designTest("design: the primary button is the reference's pill, in both themes", () => {
assert.equal(decl(".primary", "width"), "100%");
assert.equal(decl(".primary", "min-height"), "48px", "ref draws it 48 tall");
assert.equal(decl(".primary", "font-weight"), "700", "and labelled at 700");
assert.equal(decl(".primary", "border-radius"), "var(--r-pill)");
assert.equal(decl(".secondary", "border-radius"), "var(--r-pill)");
assert.equal(ruleFor(sheet.rules, ":root")!.decls.get("--r-pill"), "9999px");
assert.equal(decl(".menu-button", "border-radius"), "50%");
assert.equal(decl(".primary", "background"), "var(--accent)");
assert.equal(decl(".primary", "color"), "var(--on-accent)",
"the label is a token, because the dark theme's accent is a light blue");
assert.equal(decl(".primary", "font-size"), "1rem");
});
designTest("design: a card is a 1px hairline at one shared radius, and the chosen one is accent", () => {
assert.equal(ruleFor(sheet.rules, ":root")!.decls.get("--r-card"), "20px", "ref's radius for a card or a field");
assert.equal(decl(".choice", "border"), "1px solid var(--line)");
assert.equal(decl(".choice", "border-radius"), "var(--r-card)");
assert.equal(decl(".rows", "border"), "1px solid var(--line)");
assert.equal(decl(".rows", "border-radius"), "var(--r-card)");
assert.equal(decl(".entry", "border-radius"), "var(--r-card)");
for (const selector of [".choice", ".rows", ".entry"]) {
assert.equal(decl(selector, "box-shadow"), undefined, `${selector} must not carry a shadow`);
}
assert.equal(decl(".menu", "box-shadow"), "var(--shadow-pop)");
assert.equal(decl('.choice[aria-pressed="true"]', "border-color"), "var(--accent)");
assert.equal(decl(".info", "background"), "var(--surface)", "the landing screen's info panel is a card like any other");
assert.equal(decl(".notice", "background"), "var(--warn-bg)", "waiting and repriced are the warn ground");
assert.equal(decl(".warn", "background"), "var(--danger-bg)", "a failure or a loss is the danger ground");
});
designTest("design: a landed copy is not overwritten by the hover it is under", () => {
const copyable = [".primary", ".primary.outline", ".secondary", ".secondary.inline"];
for (const rule of sheet.rules) {
if (!/:hover|:active/.test(rule.selector)) continue;
const base = rule.selector.replace(/:(hover|active|not\([^)]*\))/g, "").trim();
if (!copyable.includes(base)) continue;
assert.match(rule.selector, /:not\(\.copied\)/,
`${rule.selector} would repaint a confirmation the buyer is looking at`);
}
assert.equal(decl(".primary.copied", "color"), "var(--ok-fg)");
});
designTest("design: the shell paints the right ground before the stylesheet lands", () => {
const shell = readFileSync(new URL("../../public/index.html", import.meta.url), "utf8");
const root = ruleFor(sheet.rules, ":root")!;
const dark = darkQuery();
for (const [scheme, token] of [["light", root], ["dark", dark]] as const) {
const ground = token.decls.get("--bg")!;
assert.ok(shell.includes(`<meta name="theme-color" content="${ground}" media="(prefers-color-scheme: ${scheme})">`),
`theme-color for ${scheme} must be ${ground}, or the browser frames the page in the wrong colour`);
}
});
designTest("design: the footer is a hairline rule then a centred accent link", () => {
assert.equal(decl("footer a", "border-top"), "1px solid var(--line)");
assert.equal(decl("footer a", "color"), "var(--accent)");
assert.equal(decl("footer", "text-align"), "center");
assert.equal(decl("footer a", "font-size"), ".875rem");
});
designTest("design: the hero is a CSS background, relative to the stylesheet's own directory", () => {
const root = ruleFor(sheet.rules, ":root")!;
const hero = root.decls.get("--hero");
assert.ok(hero !== undefined, "--hero must be a token, so the theme can swap it");
assert.match(hero, /^url\([^/][^)]*\.png\)$/, `the URL must be relative, and got "${hero}"`);
assert.equal(decl(".hero", "background-image"), "var(--hero)");
assert.equal(decl(".hero", "height"), "min(337px, 36vh)", "the mockup's 337 tall, giving way on a short screen");
assert.equal(decl(".hero", "width"), "auto", "the width follows from the ratio");
assert.equal(decl(".hero", "aspect-ratio"), "460 / 578", "at the source image's own proportions");
});
const SCALE: readonly string[] = [
"2.25rem", "1.875rem", "1.625rem", "1.375rem", "1.25rem", "1.125rem", "1rem", ".875rem", ".75rem",
];
const WEIGHTS: readonly string[] = ["400", "500", "600", "700", "800"];
function everyRule(): Array<{ selector: string; decls: Map<string, string>; phone: boolean }> {
const out = sheet.rules.map((r) => ({ ...r, phone: false }));
for (const block of sheet.media) {
if (block.query.replace(/\s+/g, "") !== "(max-width:560px)") continue;
for (const r of block.rules) out.push({ ...r, phone: true });
}
return out;
}
function phoneRules(): Array<{ selector: string; decls: Map<string, string> }> {
return sheet.media
.filter((m) => m.query.replace(/\s+/g, "") === "(max-width:560px)")
.flatMap((m) => m.rules);
}
// The last declaration for the selector in the band wins, as the browser resolves it.
function phoneDecl(selector: string, property: string): string | undefined {
let value: string | undefined;
for (const rule of phoneRules()) {
if (rule.selector !== selector) continue;
const found = rule.decls.get(property);
if (found !== undefined) value = found;
}
return value;
}
function specificity(selector: string): [number, number, number] {
const bare = selector.replace(/\s*[>+~]\s*/g, " ");
const ids = (bare.match(/#[\w-]+/g) ?? []).length;
const classes = (bare.match(/\.[\w-]+|\[[^\]]+\]|:[\w-]+\([^)]*\)|:(?!:)[\w-]+/g) ?? []).length;
const elements = (bare.match(/(^|\s)[a-z][\w-]*/g) ?? []).length;
return [ids, classes, elements];
}
const beats = (a: [number, number, number], b: [number, number, number]): boolean =>
a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2];
const subject = (selector: string): string => selector.trim().split(/\s*[>+~]\s*|\s+/).pop()!;
designTest("type: every size the sheet declares is a step of the reference scale", () => {
const offScale: string[] = [];
for (const rule of everyRule()) {
const size = rule.decls.get("font-size");
if (size === undefined) continue;
if (rule.selector === "body") {
assert.equal(size, "16px", "the root size the whole scale is relative to");
continue;
}
if (!SCALE.includes(size)) offScale.push(`${rule.phone ? "@phone " : ""}${rule.selector} { font-size: ${size} }`);
}
assert.deepEqual(offScale, [], `these sizes stand on no step of the scale: ${offScale.join(", ")}`);
});
designTest("type: every weight is a rung of one ladder, and 800 belongs to the heading alone", () => {
const wrong: string[] = [];
for (const rule of everyRule()) {
const weight = rule.decls.get("font-weight");
if (weight === undefined) continue;
if (!WEIGHTS.includes(weight)) wrong.push(`${rule.selector} { font-weight: ${weight} }`);
else if (weight === "800") assert.equal(rule.selector, "h1", "only the primary heading is 800");
}
assert.deepEqual(wrong, [], `these weights are off the ladder ${WEIGHTS.join("/")}: ${wrong.join(", ")}`);
});
designTest("type: every heading size is declared with the line-height the reference pairs it with", () => {
const PAIRS: ReadonlyArray<readonly [string, string, string]> = [
["h1", "2.25rem", "3rem"],
["h1.tight", "1.875rem", "2.5rem"],
];
for (const [selector, size, height] of PAIRS) {
assert.equal(decl(selector, "font-size"), size, `${selector} takes the ${size} step`);
assert.equal(decl(selector, "line-height"), height, `${selector} takes the ${size} step's own line-height`);
}
const PHONE: ReadonlyArray<readonly [string, string, string]> = [
["h1", "1.875rem", "2.5rem"],
["h1.tight", "1.625rem", "2.125rem"],
];
for (const [selector, size, height] of PHONE) {
assert.equal(phoneDecl(selector, "font-size"), size, `@phone ${selector} takes the ${size} step`);
assert.equal(phoneDecl(selector, "line-height"), height, `@phone ${selector} pairs ${size} with its own line-height`);
}
});
designTest("type: a block title and its copy are one rule across all four grounds", () => {
const title = sheet.rules.filter((r) => /^\.(notice|warn|info) \.title$/.test(r.selector) && r.decls.has("font-size"));
assert.equal(title.length, 3, "the three grounds must share one title rule, and got " + title.map((r) => r.selector).join(", "));
for (const rule of title) {
assert.equal(rule.decls.get("font-size"), "1rem", `${rule.selector} is the body step`);
assert.equal(rule.decls.get("font-weight"), "600", `${rule.selector} is a title weight, not a label weight`);
}
const body = sheet.rules.filter((r) => /^\.(notice|warn|info) p$/.test(r.selector) && r.decls.has("font-size"));
assert.equal(body.length, 3, "and one body rule, and got " + body.map((r) => r.selector).join(", "));
for (const rule of body) assert.equal(rule.decls.get("font-size"), ".875rem", `${rule.selector} is the small step`);
for (const rule of everyRule()) {
if (!/^\.(notice|warn|info)(\s|$)/.test(rule.selector)) continue;
if (/\.(notice|warn|info) (\.title|p)$/.test(rule.selector)) continue;
for (const property of ["font-size", "font-weight"]) {
assert.equal(rule.decls.get(property), undefined,
`${rule.selector} restates ${property}; the four grounds must differ only in colour`);
}
}
});
designTest("type: block copy carries no leading of its own — the page has one", () => {
assert.equal(decl("body", "line-height"), "1.5", "the page's one leading");
for (const selector of [".notice p", ".warn p", ".info p", ".choice .feature", ".muted", ".lede"]) {
const declared = decl(selector, "line-height");
assert.ok(declared === undefined || declared === "1.5",
`${selector} declares line-height: ${declared}, which is a second leading for the same copy`);
}
});
designTest("type: `Legend, 12 months` is one title, on the history list's rows and on the paid-no-code screen's summary", () => {
const rule = sheet.rules.find((r) => r.selector === ".rows.field > .name" && r.decls.has("font-size"));
assert.ok(rule, "the paid-no-code screen's summary title must be styled, not left to inherit the body");
const row = sheet.rules.find((r) => r.selector === ".entry .name" && r.decls.has("font-size"));
assert.ok(row, "the history list's row title must be styled");
for (const [name, r] of [["paidNoCode", rule], ["the history list", row]] as const) {
assert.equal(r.decls.get("font-size"), "1.125rem", `${name}'s purchase title takes the same step`);
assert.equal(r.decls.get("font-weight"), "600", `${name}'s purchase title takes the same weight`);
}
assert.equal(phoneDecl(".entry .name", "font-size"), "1rem");
assert.equal(phoneDecl(".rows.field > .name", "font-size"), "1rem");
const order = {
orderId: "inv_9f3a", badgeType: "legend", months: 12,
createdAt: "2026-08-28T11:46:00Z", status: "open" as const,
};
const paidNoCode = render(screens.paidNoCode({ order, settledAt: undefined }));
const history = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true,
rows: [{ kind: "open" as const, order }], onOpen: () => {}, onStart: () => {},
}));
assert.equal(paidNoCode.all("div.name")[0]!.textContent, "Legend, 12 months");
assert.equal(history.all("div.name")[0]!.textContent, "Legend, 12 months",
"the same string, so it must not be two type roles");
});
designTest("type: a tier leads with its name and a duration with its price — never both", () => {
assert.equal(decl(".choice .name", "font-weight"), "600", "a tier's name leads");
assert.equal(decl(".choice .price", "font-weight"), "400", "and its price is stated plainly");
assert.equal(decl(".choice.term .name", "font-weight"), "500", "a duration's term steps back");
assert.equal(decl(".choice.term .price", "font-weight"), "600", "and its price leads");
assert.equal(decl(".choice.term .price", "font-size"), "1.375rem", "at a larger step than the term above it");
for (const kind of ["", ".term"]) {
const name = decl(`.choice${kind} .name`, "font-weight") ?? decl(".choice .name", "font-weight");
const price = decl(`.choice${kind} .price`, "font-weight") ?? decl(".choice .price", "font-weight");
assert.notEqual(name, price, `.choice${kind} weights its name and its price alike`);
}
assert.equal(decl(".choice .badge-art + .name", "font-size"), "1.25rem");
assert.equal(decl(".choice .badge-art ~ .price", "font-size"), "1.125rem");
assert.equal(decl(".choice .badge-art ~ .price", "font-weight"), undefined,
"artwork changes the step, never the weight");
});
designTest("type: the struck total is a step of the scale, and stacks at every width", () => {
assert.equal(decl(".choice .price .was", "font-size"), ".875rem");
assert.equal(decl(".choice .price .was", "color"), "var(--ink-soft)");
assert.equal(decl(".choice .price .was", "display"), "block");
assert.equal(phoneDecl(".choice .price .was", "display"), undefined,
"the stack is the layout, not a phone variant of it");
});
designTest("type: the phone band steps the selectors that can actually win", () => {
const sized = sheet.rules.filter((r) => r.decls.has("font-size"));
for (const banded of phoneRules()) {
const step = banded.decls.get("font-size");
if (step === undefined) continue;
const mine = specificity(banded.selector);
for (const base of sized) {
if (subject(base.selector) !== subject(banded.selector)) continue;
if (!beats(specificity(base.selector), mine)) continue;
if (base.decls.get("font-size") === step) continue;
assert.ok(phoneRules().some((r) => r.selector === base.selector && r.decls.has("font-size")),
`@phone ${banded.selector} steps to ${step} but is beaten by `
+ `${base.selector} { font-size: ${base.decls.get("font-size")} }, which the band never steps — `
+ "the declaration reaches nothing");
}
}
});
designTest("type: figures that tick or line up are set in tabular numerals", () => {
const rule = sheet.rules.filter((r) => r.decls.get("font-variant-numeric") === "tabular-nums");
const claimed = new Set(rule.map((r) => r.selector));
for (const selector of [".rate", ".notice .title", ".choice .price", ".row", ".entry .meta"]) {
assert.ok(claimed.has(selector), `${selector} holds a figure and must set tabular numerals`);
}
});
designTest("type: the uppercase micro-labels are one rule, tracked in em", () => {
assert.equal(decl(".label", "text-transform"), "uppercase");
assert.equal(decl(".label", "font-size"), ".875rem", "the floor for text: 14px, as simplex.domains keeps it");
assert.equal(decl(".label", "font-weight"), "700");
assert.match(decl(".label", "letter-spacing") ?? "", /em$/, "tracking follows the size, so it survives a step down");
for (const property of ["font-size", "font-weight", "letter-spacing", "text-transform"]) {
assert.equal(decl(".label.standalone", property), undefined,
`.label.standalone restates ${property}; only its place on the screen differs`);
}
assert.match(decl(".code", "letter-spacing") ?? "", /em$/);
});
designTest("type: the monospace runs are sized against the copy beside them", () => {
assert.equal(decl(".code", "font-size"), "1.375rem");
assert.equal(decl(".code", "font-family"), "var(--mono)");
assert.equal(decl(".mono", "font-size"), "1rem");
assert.equal(decl(".mono", "font-family"), "var(--mono)");
assert.equal(decl(".entry .code-row code", "font-size"), ".875rem", ".entry .code-row code is the small step");
assert.equal(decl(".warn p", "font-size"), ".875rem",
"which is the size of a notice's own copy");
assert.equal(decl(".entry .mono", "font-size"), undefined);
});
const officialAssets: ReadonlyArray<readonly [string, string, (source: string) => string]> = [
["wordmark-light.svg", "../../../../../website/src/img/simplex.svg", (x) => x],
// wordmark-dark is the official file with its navy lettering set to white, as simplex.chat's own dark header does.
["wordmark-dark.svg", "../../../../../website/src/img/simplex.svg",
(x) => x.replace(/fill="#030749"/g, 'fill="#FFFFFF"')],
["symbol-light.svg", "../../../../../media-logos/simplex-symbol-light.svg", (x) => x],
["symbol-dark.svg", "../../../../../media-logos/simplex-symbol-dark.svg", (x) => x],
];
designTest("design: the brand artwork is the official file, byte for byte", () => {
for (const [shipped, source, transform] of officialAssets) {
const served = readFileSync(new URL(`../../public/img/${shipped}`, import.meta.url), "utf8");
const official = readFileSync(new URL(source, import.meta.url), "utf8");
assert.equal(served, transform(official),
`public/img/${shipped} is not ${source} — the mark must be the file, never a redrawing of it`);
}
});
designTest("design: no module reaches for a sink that turns a string into markup", () => {
const sinks = /\.innerHTML|\.outerHTML|insertAdjacentHTML|document\.write|\beval\(|new Function\(|srcdoc/;
const dir = new URL("../../src/", import.meta.url);
for (const name of readdirSync(dir).filter((f) => f.endsWith(".ts"))) {
const source = readFileSync(new URL(name, dir), "utf8");
assert.equal(sinks.test(source), false, `src/${name} reaches for a markup sink`);
}
});
designTest("design: no module draws the mark, so no transcription can drift", () => {
assert.equal("simplexLogo" in icons, false,
"the mark is served as a file and painted by the stylesheet; a builder here is a second copy of it");
const source = readFileSync(new URL("../../src/icons.ts", import.meta.url), "utf8");
assert.equal(source.includes("M3.02972 8.59396"), false, "the mark's path data is back in the module");
});
designTest("design: the wordmark is the header's, themed and precachable", () => {
const root = ruleFor(sheet.rules, ":root")!;
const wordmark = root.decls.get("--wordmark");
assert.ok(wordmark !== undefined, "--wordmark must be a token, so the theme can swap it");
assert.match(wordmark, /^url\([^/][^)]*\.svg\)$/, `the URL must be relative, and got "${wordmark}"`);
assert.equal(decl(".brand", "background-image"), "var(--wordmark)");
assert.equal(decl(".brand", "aspect-ratio"), "161 / 40", "at the source file's own proportions");
});
function testChrome(over: Partial<Parameters<typeof screens.chrome>[0]> = {}): ReturnType<typeof screens.chrome> {
return screens.chrome({
onNewPurchase: () => {}, onHistory: () => {},
theme: "system", onTheme: () => {}, onToggle: () => {}, onHome: () => {}, ...over,
});
}
designTest("design: the chrome is a wordmark home link and a menu, and holds no order", () => {
let started = 0;
let history = 0;
const ui = testChrome({
onNewPurchase: () => { started += 1; },
onHistory: () => { history += 1; },
});
const bar = render(ui.node);
const brand = bar.all("a.brand")[0]!;
assert.equal(brand.getAttribute("href"), "/", "the wordmark goes home");
assert.equal(brand.getAttribute("target"), null, "standalone, it stays in this page");
assert.equal(brand.getAttribute("aria-label"), "SimpleX", "and carries the name, since it has no text");
const trigger = bar.all("button.menu-button")[0]!;
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(trigger.getAttribute("aria-controls"), screens.MENU_ID);
assert.equal(bar.all("svg.bars").length, 1, "the hamburger is drawn, not typed");
assert.deepEqual(bar.all("button.menu-item").map((b) => b.textContent),
[screens.NEW_PURCHASE, screens.PURCHASE_HISTORY]);
for (const b of bar.all("button.menu-item")) b.click();
assert.deepEqual([started, history], [1, 1]);
assert.equal(bar.all("button.danger").length, 0, "the menu holds nothing destructive");
assert.equal(bar.all("div.menu-section").length, 2);
assert.equal(/SB-|order=|inv_/.test(bar.serialize()), false, bar.serialize());
});
designTest("design: the theme control is the reference's segmented setting, over three values", () => {
const chosen: string[] = [];
const ui = testChrome({ theme: "dark", onTheme: (t) => chosen.push(t) });
const bar = render(ui.node);
assert.ok(bar.textContent.includes(screens.THEME_LABEL));
const segments = bar.all("button.segment");
assert.deepEqual(segments.map((b) => b.textContent), ["Light", "Dark", "System"]);
assert.deepEqual(segments.map((b) => b.getAttribute("aria-pressed")), ["false", "true", "false"],
"the stored theme is the one drawn as chosen");
segments[0]!.click();
assert.deepEqual(chosen, ["light"], "the choice is reported, and applying it is main.ts's");
assert.deepEqual(segments.map((b) => b.getAttribute("aria-pressed")), ["false", "true", "false"]);
ui.showTheme("light");
assert.deepEqual(segments.map((b) => b.getAttribute("aria-pressed")), ["true", "false", "false"]);
});
designTest("design: the menu opens, closes on choosing, and lists what the keyboard can reach", () => {
const toggles: boolean[] = [];
const ui = testChrome({ onToggle: (open) => toggles.push(open) });
const bar = render(ui.node);
const trigger = bar.all("button.menu-button")[0]!;
const menu = bar.all("div.menu")[0]!;
assert.equal(menu.getAttribute("role"), "dialog", "so a screen reader knows the page has a layer over it");
assert.equal(menu.hasAttribute("hidden"), true, "it starts closed");
trigger.click();
assert.equal(ui.isOpen(), true);
assert.equal(menu.hasAttribute("hidden"), false);
assert.equal(trigger.getAttribute("aria-expanded"), "true");
assert.equal(ui.focusables().length, 5);
bar.all("button.menu-item")[1]!.click();
assert.equal(ui.isOpen(), false, "choosing an item closes it before the screen changes");
assert.equal(menu.hasAttribute("hidden"), true);
trigger.click();
ui.close();
assert.equal(ui.isOpen(), false);
assert.deepEqual(toggles, [true, false, true, false]);
});
designTest("design: the withholding reaches the menu — no second invoice while a card is in flight", () => {
const ui = testChrome();
const bar = render(ui.node);
const fresh = bar.all("button.menu-item")[0]!;
assert.equal(fresh.textContent, screens.NEW_PURCHASE);
ui.offerNewPurchase(false);
assert.equal(fresh.hasAttribute("hidden"), true,
"the create endpoint has no idempotency key, so a menu that offered one would be a second charge");
assert.equal(ui.focusables().length, 4, "and the keyboard cannot reach what the eye cannot see");
ui.offerNewPurchase(true);
assert.equal(fresh.hasAttribute("hidden"), false);
});
designTest("design: each badge is its source file's viewBox, gradient stops and glyph", () => {
const expected: Record<string, string[]> = {
supporter: ["#29f5ff", "#29f5ff", "#527eed", "#3669e9"],
legend: ["#29f5ff", "#26dee8", "#3064ea", "#001064"],
};
for (const [tier, colours] of Object.entries(expected)) {
assert.equal(icons.hasBadgeArt(tier), true);
const art = render(icons.badgeIcon(tier as "supporter" | "legend"));
assert.equal(art.getAttribute("viewBox"), "8.25 8.25 300 399", `${tier}'s box is the source file's`);
assert.equal(art.getAttribute("aria-hidden"), "true");
assert.deepEqual(art.all("stop").map((s) => s.getAttribute("stop-color")), colours,
`${tier}'s gradient must be the file's four stops, in order`);
assert.deepEqual(art.all("stop").map((s) => s.getAttribute("offset")), ["0%", "5%", "50%", "100%"].map(
(o, i) => (tier === "supporter" ? ["0%", "5%", "95%", "100%"][i]! : o)));
const paths = art.all("path");
assert.equal(paths.length, 2, "a body and a glyph");
assert.match(paths[0]!.getAttribute("fill") ?? "", /^url\(#/, "the body takes the gradient");
assert.equal(paths[1]!.getAttribute("fill"), "#ffffff", "and the glyph is white");
}
assert.equal(icons.hasBadgeArt("investor"), false, "a tier the catalog does not sell has no art here");
});
designTest("design: two badges on one screen get two gradients, or both would be one colour", () => {
// A gradient id is document-wide, so two with the same id would paint both in the first one's colour.
const ids = [icons.badgeIcon("supporter"), icons.badgeIcon("legend"), icons.badgeIcon("supporter")]
.map((n) => render(n).all("linearGradient")[0]!.getAttribute("id"));
assert.equal(new Set(ids).size, 3, `every gradient needs its own id, and got ${JSON.stringify(ids)}`);
});
designTest("design: the tier list's tier cards carry the badge art, one each, above the name", () => {
const p = render(screens.tiers({
tiers: [
{ priceId: "price_supporter", badgeType: "supporter", name: "Supporter", price: "$7 / month", features: ["2 GB files"], disabled: false },
{ priceId: "price_legend", badgeType: "legend", name: "Legend", price: "$70 / month", features: ["5 GB files"], disabled: false },
],
selected: "price_legend", onSelect: () => {}, onContinue: () => {}, onBack: () => {},
}));
const cards = p.all("button.choice");
assert.equal(cards.length, 2);
for (const [i, card] of cards.entries()) {
const art = card.all("svg.badge-art");
assert.equal(art.length, 1, `tier ${i} must draw exactly one badge`);
assert.equal(art[0]!.getAttribute("viewBox"), "8.25 8.25 300 399");
assert.equal(card.children.indexOf(art[0]!), 0, "and it is the first thing in the card, above the name");
}
assert.equal(cards[0]!.all("stop")[3]!.getAttribute("stop-color"), "#3669e9");
assert.equal(cards[1]!.all("stop")[3]!.getAttribute("stop-color"), "#001064");
});
designTest("design: a tier with no artwork still renders, without a badge", () => {
const p = render(screens.tiers({
tiers: [{ priceId: "p", badgeType: "founder", name: "Founder", price: "$1 / month", features: [], disabled: false }],
selected: undefined, onSelect: () => {}, onContinue: () => {}, onBack: () => {},
}));
assert.equal(p.all("svg.badge-art").length, 0, "no art is better than the wrong badge");
assert.ok(p.textContent.includes("Founder"), "and the tier is still on the screen");
});
designTest("design: the order summary's method row carries all three payment marks, in order", () => {
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "xmr",
onSelect: () => {}, onPay: () => {}, onBack: () => {},
}));
const cards = p.all("button.choice");
assert.equal(cards.length, 3);
const marks = cards.map((c) => c.all("svg.mark"));
for (const [i, m] of marks.entries()) {
assert.equal(m.length, 1, `method ${i} must carry exactly one mark`);
assert.equal(m[0]!.getAttribute("viewBox"), "0 0 24 24", "every mark is drawn in the same 24×24 box");
assert.equal(m[0]!.getAttribute("aria-hidden"), "true");
}
assert.equal(marks[0]![0]!.all("path").length, 0, "the card is a rect and a line, not a filled glyph");
assert.equal(marks[0]![0]!.all("rect")[0]!.getAttribute("stroke"), "currentColor");
assert.equal(marks[0]![0]!.all("line")[0]!.getAttribute("stroke"), "currentColor");
assert.equal(marks[1]![0]!.all("path")[0]!.getAttribute("fill"), "#F7931A");
assert.deepEqual(marks[2]![0]!.all("path").map((p) => p.getAttribute("fill")), ["#F60", "#4C4C4C"],
"the Monero glyph is the official two-colour mark, as simplex.domains draws it");
assert.deepEqual(cards.map((c) => c.textContent), ["Card", "Bitcoin", "Monero"], "the card first, then the two coins");
});
designTest("design: no screen repeats the mark — the header carries it, and the code screen has its tick", () => {
const order = {
orderId: "inv_9f3a", badgeType: "legend", months: 12,
createdAt: "2026-08-28T11:46:00Z", status: "open" as const,
};
const everyScreen: Array<[string, unknown]> = [
["the landing screen", screens.landing({ onStart: () => {} })],
["the catalog-changed screen", screens.catalogChanged(() => {})],
["awaitingConfirmation", screens.awaitingConfirmation({ order, invoice: undefined, method: undefined, gaveUp: false, onCheckAgain: () => {} })],
["windowClosed", screens.windowClosed({ onNewInvoice: () => {}, order, invoice: { status: "expired" } })],
["paidNoCode", screens.paidNoCode({ order, settledAt: undefined })],
["unknownOrder", screens.unknownOrder(() => {})],
["invoiceFailure", screens.invoiceFailure({ onBack: () => {}, onRetry: () => {} })],
];
for (const [name, node] of everyScreen) {
assert.equal(render(node).all("svg.logo").length, 0, `${name} must not draw a second mark`);
assert.equal(render(node).all("a.brand").length, 0, `${name} must not draw a second wordmark`);
}
const codeIssued = render(screens.codeIssued({ code: "SB-YDC8A-YGQTM-PUYZ9-2TUXP", savedLocally: true }));
assert.equal(codeIssued.all("div.tick").length, 1, "the code screen opens on the settled tick");
});
designTest("design: every image the sheet asks for is precached, so the landing screen works offline", () => {
const worker = readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8");
const build = /const BUILD = "([0-9a-f]+)";/.exec(worker);
assert.ok(build, "the worker must name a build");
const root = ruleFor(sheet.rules, ":root")!;
const dark = darkQuery();
const tokens = ["--hero", "--wordmark"].flatMap((t) => [root.decls.get(t)!, dark.decls.get(t)!]);
for (const token of tokens) {
const file = /^url\(([^)]+)\)$/.exec(token)![1]!;
assert.ok(worker.includes(`\${ASSETS}${file}\``),
`the stylesheet asks for ${file}, and the worker does not precache it`);
}
const shell = readFileSync(new URL("../../public/index.html", import.meta.url), "utf8");
for (const icon of ["symbol-light.svg", "symbol-dark.svg"]) {
assert.ok(shell.includes(`/assets/${build[1]!}/${icon}`), `the shell must name ${icon} under this build`);
assert.ok(worker.includes(`\${ASSETS}${icon}\``), `and the worker must precache ${icon}`);
}
assert.match(shell, /<link rel="icon"[^>]*media="\(prefers-color-scheme: light\)"/,
"and pick the variant by theme, as the wordmark does");
});
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { RETURN_URL_MESSAGE, THEME_MESSAGE, returnUrlFromMessage, themeFromMessage, trustedHost } from "../src/embed.js";
test("embed: only https simplex.chat and its subdomains may drive the theme", () => {
for (const origin of ["https://simplex.chat", "https://www.simplex.chat", "https://badges.simplex.chat"]) {
assert.equal(trustedHost(origin), true, `${origin} is the site`);
}
for (const origin of [
"http://simplex.chat",
"https://simplex.chat.attacker.com",
"https://notsimplex.chat",
"https://evil.com",
"null",
"",
]) {
assert.equal(trustedHost(origin), false, `${origin} is not the site`);
}
});
test("embed: a theme message yields its theme, and anything else yields undefined", () => {
for (const theme of ["light", "dark", "system"]) {
assert.equal(themeFromMessage({ type: THEME_MESSAGE, theme }), theme, `${theme} is a theme`);
}
for (const data of [
{ type: "other", theme: "dark" },
{ type: THEME_MESSAGE, theme: "neon" },
{ type: THEME_MESSAGE },
{ theme: "dark" },
"dark", 42, null, undefined, [THEME_MESSAGE],
]) {
assert.equal(themeFromMessage(data), undefined, `${JSON.stringify(data)} carries no theme`);
}
});
test("embed: a return-url message yields a valid http(s) url, and anything else undefined", () => {
assert.equal(returnUrlFromMessage({ type: RETURN_URL_MESSAGE, url: "https://simplex.chat/badges/" }), "https://simplex.chat/badges/");
assert.equal(returnUrlFromMessage({ type: RETURN_URL_MESSAGE, url: "http://localhost:8001/badges/" }), "http://localhost:8001/badges/");
for (const bad of [
{ type: RETURN_URL_MESSAGE, url: "not a url" },
{ type: RETURN_URL_MESSAGE, url: "javascript:alert(1)" },
{ type: RETURN_URL_MESSAGE, url: 42 },
{ type: "simplex-theme", url: "https://simplex.chat/" },
null,
"https://simplex.chat/",
]) {
assert.equal(returnUrlFromMessage(bad), undefined, `${JSON.stringify(bad)} is not a return url`);
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,799 @@
import { test } from "node:test";
import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
import { once } from "node:events";
import { spawn, type ChildProcess } from "node:child_process";
import { createServer, connect } from "node:net";
import { fileURLToPath } from "node:url";
import { createInvoice, waitForChange, inferMethod, parseInvoiceView, ApiError, AbortedError } from "../src/api.js";
import { generate, display, normalise, checkChar, hash } from "../src/codes.js";
import { Store } from "../src/store.js";
const apiTest = timedTest(2000);
// The sleep uses a macrotask, not a microtask, because a loop of microtask sleeps would starve the test timeout.
const instantSleep = (): Promise<void> => new Promise((resolve) => { setImmediate(resolve); });
type MockResponse = { status: number; body?: unknown; headers?: Record<string, string> | undefined; badJson?: true } | Error;
function fetchReturning(...responses: MockResponse[]) {
let i = 0;
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
const consumed: boolean[] = [];
const fn = async (url: string, init?: RequestInit) => {
const idx = calls.length;
calls.push({ url, init });
consumed.push(false);
const r = responses[Math.min(i++, responses.length - 1)]!;
if (r instanceof Error) throw r;
const headerMap = r.headers ?? {};
const headers = {
get: (name: string) => {
const key = Object.keys(headerMap).find((k) => k.toLowerCase() === name.toLowerCase());
return key ? headerMap[key]! : null;
},
};
const json = async () => {
if (r.badJson) throw new SyntaxError("Unexpected end of JSON input");
return r.body;
};
const text = async () => { consumed[idx] = true; return JSON.stringify(r.body ?? {}); };
return { ok: r.status < 400, status: r.status, json, text, headers } as unknown as Response;
};
return { fn: fn as unknown as typeof fetch, calls, consumed };
}
const fullCreated = {
invoiceId: "inv_9f3a", badgeType: "legend", months: 12,
amount: 4200, currency: "usd", expiresAt: "2026-08-28T12:00:00Z",
address: "48HqK2XmVexampleAddress9fRtWc", cryptoAmount: "1.482", cryptoCurrency: "xmr" as const,
};
const cardCreated = {
invoiceId: "inv_card1", badgeType: "supporter", months: 1,
amount: 700, currency: "usd", expiresAt: "2026-08-28T12:00:00Z",
clientSecret: "cs_test_abc123",
};
apiTest("api: createInvoice sends exactly the four fields, as a POST", async () => {
const { fn, calls } = fetchReturning({ status: 200, body: fullCreated });
await createInvoice({ priceId: "p", offerId: "o", method: "xmr", codeHash: "h" }, fn);
assert.equal(calls[0]!.url, "/api/invoice");
assert.equal(calls[0]!.init!.method, "POST");
const parsed = JSON.parse(String(calls[0]!.init!.body));
assert.deepEqual(parsed, { priceId: "p", offerId: "o", method: "xmr", codeHash: "h" });
assert.deepEqual(Object.keys(parsed).sort(), ["codeHash", "method", "offerId", "priceId"]);
});
apiTest("api: createInvoice omits offerId entirely when absent, rather than sending it as undefined/null", async () => {
const { fn, calls } = fetchReturning({ status: 200, body: fullCreated });
await createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn);
const parsed = JSON.parse(String(calls[0]!.init!.body));
assert.deepEqual(Object.keys(parsed).sort(), ["codeHash", "method", "priceId"]);
});
apiTest("api: createInvoice returns the 200 body parsed field by field, not a stub", async () => {
const { fn } = fetchReturning({ status: 200, body: fullCreated });
const got = await createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn);
assert.deepEqual(got, fullCreated);
});
apiTest("api: createInvoice accepts a card-shaped 200 for a card request", async () => {
const { fn } = fetchReturning({ status: 200, body: cardCreated });
const got = await createInvoice({ priceId: "p", method: "card", codeHash: "h" }, fn);
assert.deepEqual(got, cardCreated);
});
apiTest("api: createInvoice maps error codes to typed errors", async () => {
for (const [status, code] of [[409, "code_conflict"], [400, "catalog_changed"], [429, "rate_limited"], [503, "provider_unavailable"]] as const) {
const { fn } = fetchReturning({ status, body: { error: code } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "card", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === code,
);
}
});
apiTest("api: an unrecognised server error code maps to 'unknown', not passed through verbatim", async () => {
const { fn } = fetchReturning({ status: 400, body: { error: "a_code_this_client_predates" } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "card", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === "unknown",
);
});
apiTest("api: createInvoice rejects a 200 missing a required field, rather than returning it half-built", async () => {
const { badgeType: _drop, ...missingBadgeType } = fullCreated;
const { fn } = fetchReturning({ status: 200, body: missingBadgeType });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === "invalid_response",
);
});
apiTest("api: createInvoice rejects a 200 whose required field has the wrong type", async () => {
const { fn } = fetchReturning({ status: 200, body: { ...fullCreated, months: "12" } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === "invalid_response",
);
});
apiTest("api: createInvoice rejects a 200 whose months or amount is zero, negative, or fractional", async () => {
for (const bad of [{ months: 0 }, { months: -1 }, { months: 1.5 }, { amount: 0 }, { amount: -100 }, { amount: 12.5 }]) {
const { fn } = fetchReturning({ status: 200, body: { ...fullCreated, ...bad } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === "invalid_response",
`bad=${JSON.stringify(bad)}`,
);
}
});
apiTest("api: createInvoice rejects a 200 with an unrecognised cryptoCurrency", async () => {
const { fn } = fetchReturning({ status: 200, body: { ...fullCreated, cryptoCurrency: "eth" } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === "invalid_response",
);
});
apiTest("api: a read refuses a field of the wrong type rather than passing it on", () => {
const wrong = [
{ currency: 42 }, { amount: "42000" }, { amountPaid: "not-a-number" }, { months: "12" },
{ months: 0 }, { amount: Number.NaN }, { expiresAt: {} },
{ paidInFull: "true" }, { cryptoAmountPaid: 1.482 }, { settledAt: 0 },
{ requiredConfirmations: "6" }, { cryptoCurrency: "eth" }, { badgeType: false },
{ amount: -1250 }, { amount: 42000.5 }, { amountPaid: -1 }, { requiredConfirmations: 2.5 },
{ amount: 0 },
];
for (const bad of wrong) {
assert.equal(parseInvoiceView({ status: "open", ...bad }), null, `must refuse ${JSON.stringify(bad)}`);
}
assert.equal(parseInvoiceView([1, 2, 3]), null, "an array is not a body");
assert.equal(parseInvoiceView("open"), null);
assert.equal(parseInvoiceView(null), null);
});
apiTest("api: a read keeps every well-typed field and reads null as not sent", () => {
const full = {
status: "paid", amountPaid: 42000, cryptoAmountPaid: "1.482", settledAt: "2026-08-28T12:05:00Z",
badgeType: "legend", months: 12, amount: 42000, currency: "usd", expiresAt: "2026-08-28T13:00:00Z",
address: "48HqK2Xm", cryptoAmount: "1.482", cryptoCurrency: "xmr",
paidInFull: true, requiredConfirmations: 6,
};
assert.deepEqual(parseInvoiceView(full), full, "nothing well-typed may be dropped on the way in");
assert.deepEqual(parseInvoiceView({ status: "open", amount: null, currency: null }), { status: "open" },
"Aeson encodes an absent Maybe as null, which is not a malformed field");
});
apiTest("api: createInvoice rejects a 200 whose body is not valid JSON", async () => {
const { fn } = fetchReturning({ status: 200, badJson: true });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === "invalid_response",
);
});
apiTest("api: createInvoice rejects a 200 whose payment-method shape contradicts the requested method", async () => {
const cryptoForCard = fetchReturning({ status: 200, body: fullCreated });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "card", codeHash: "h" }, cryptoForCard.fn),
(e: unknown) => e instanceof ApiError && e.code === "invalid_response",
);
const cardForCrypto = fetchReturning({ status: 200, body: cardCreated });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "btc", codeHash: "h" }, cardForCrypto.fn),
(e: unknown) => e instanceof ApiError && e.code === "invalid_response",
);
const wrongCrypto = fetchReturning({ status: 200, body: fullCreated });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "btc", codeHash: "h" }, wrongCrypto.fn),
(e: unknown) => e instanceof ApiError && e.code === "invalid_response",
);
});
apiTest("api: createInvoice treats a null optional field the same as an absent one (Aeson's default Maybe encoding)", async () => {
// Aeson encodes an absent Maybe field as null, not omission, so null must be accepted like an absent field.
const { fn } = fetchReturning({ status: 200, body: { ...cardCreated, address: null, cryptoAmount: null, cryptoCurrency: null } });
const got = await createInvoice({ priceId: "p", method: "card", codeHash: "h" }, fn);
assert.deepEqual(got, cardCreated);
});
apiTest("api: inferMethod reads the wire's own signal, not a session the second device never had", () => {
assert.equal(inferMethod({ clientSecret: "cs_test_x" }), "card");
assert.equal(inferMethod({ cryptoCurrency: "btc" }), "btc");
assert.equal(inferMethod({ cryptoCurrency: "xmr" }), "xmr");
assert.equal(inferMethod({}), undefined);
});
apiTest("api: waitForChange passes the current status and resolves on a change", async () => {
const { fn, calls } = fetchReturning({ status: 200, body: { status: "paid" } });
const got = await waitForChange("i1", "open", fn, instantSleep);
assert.ok(calls[0]!.url.includes("wait=open"));
assert.equal(got.status, "paid");
});
apiTest("api: waitForChange sends whatever status is on screen, not a hardcoded 'open' — resuming after expired", async () => {
const { fn, calls } = fetchReturning({ status: 200, body: { status: "paid" } });
const got = await waitForChange("i1", "expired", fn, instantSleep);
assert.ok(calls[0]!.url.includes("wait=expired"), calls[0]!.url);
assert.equal(got.status, "paid");
});
apiTest("api: waitForChange returns a payment that left the status alone", async () => {
// BTCPay reports Processing while the invoice stays open, so a body with a payment but no status change must still return.
const body = { status: "open", cryptoAmountPaid: "1.482", amountPaid: 42000 };
const { fn, calls } = fetchReturning({ status: 200, body });
const got = await waitForChange("i1", "open", fn, instantSleep, undefined, Date.now, undefined);
assert.equal(calls.length, 1, "it must not have gone round again");
assert.equal(got.cryptoAmountPaid, "1.482");
});
apiTest("api: waitForChange keeps waiting while the same payment is reported", async () => {
const body = { status: "open", cryptoAmountPaid: "1.482", amountPaid: 42000 };
let served = 0;
const fn = (async (_url: string) => {
served += 1;
const next = served >= 3 ? { status: "paid", cryptoAmountPaid: "1.482" } : body;
return { ok: true, status: 200, json: async () => next, headers: { get: () => null } };
}) as unknown as typeof fetch;
const got = await waitForChange("i1", "open", fn, instantSleep, undefined, Date.now, { paid: "1.482", paidInFull: undefined });
assert.equal(served, 3);
assert.equal(got.status, "paid");
});
apiTest("api: the provider's verdict is a change on its own, with no figure to go with it", async () => {
// Monero reports confirming with figures still zero, so a body carrying only the verdict must return.
const fn = (async () => ({
ok: true, status: 200, headers: { get: () => null },
json: async () => ({ status: "open", paidInFull: true }),
})) as unknown as typeof fetch;
const got = await waitForChange("i1", "open", fn, instantSleep, undefined, Date.now,
{ paid: undefined, paidInFull: undefined });
assert.equal(got.paidInFull, true, "the confirming screen is unreachable without this");
});
apiTest("api: waitForChange refuses seen: 'paid' outright and issues no request", async () => {
const { fn, calls } = fetchReturning({ status: 200, body: { status: "paid" } });
await assert.rejects(() => waitForChange("i1", "paid", fn, instantSleep));
assert.equal(calls.length, 0);
});
apiTest("api: a network error backs off and retries, and the delays double", async () => {
const delays: number[] = [];
const { fn } = fetchReturning(new Error("offline"), new Error("offline"), { status: 200, body: { status: "paid" } });
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [1000, 2000]);
assert.equal(got.status, "paid");
});
apiTest("api: backoff reaches the 30s cap and holds there across further failures", async () => {
const delays: number[] = [];
const { fn } = fetchReturning(
...Array.from({ length: 8 }, () => new Error("offline")),
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
assert.equal(got.status, "paid");
});
apiTest("api: a truncated 200 body backs off and retries rather than throwing a raw parse error", async () => {
const delays: number[] = [];
const { fn, calls } = fetchReturning(
{ status: 200, badJson: true },
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [1000]);
assert.equal(calls.length, 2);
assert.equal(got.status, "paid");
});
apiTest("api: a GET 200 with a null body backs off and retries rather than throwing a raw TypeError", async () => {
const delays: number[] = [];
const { fn, calls } = fetchReturning(
{ status: 200, body: null },
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [1000]);
assert.equal(calls.length, 2);
assert.equal(got.status, "paid");
});
apiTest("api: a GET 200 with an empty object backs off and retries, never reporting a spurious status change", async () => {
const delays: number[] = [];
const { fn, calls } = fetchReturning(
{ status: 200, body: {} },
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [1000]);
assert.equal(calls.length, 2);
assert.equal(got.status, "paid");
});
apiTest("api: a GET 200 with an unrecognised cryptoCurrency backs off and retries", async () => {
const delays: number[] = [];
const { fn, calls } = fetchReturning(
{ status: 200, body: { status: "open", cryptoCurrency: "eth" } },
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [1000]);
assert.equal(calls.length, 2);
assert.equal(got.status, "paid");
});
apiTest("api: a same-status 200 (the hold timing out) reissues immediately, with no sleep at all", async () => {
const { fn, calls } = fetchReturning(
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "paid" } },
);
let sleepCalls = 0;
const got = await waitForChange("i", "open", fn, async () => { sleepCalls++; });
assert.equal(sleepCalls, 0);
assert.equal(calls.length, 2);
assert.equal(got.status, "paid");
});
apiTest("api: waitForChange resolves, rather than throws, when the status becomes expired", async () => {
const { fn } = fetchReturning({ status: 200, body: { status: "expired" } });
const got = await waitForChange("i", "open", fn, instantSleep);
assert.equal(got.status, "expired");
});
apiTest("api: EVERY 404 stops the loop, whatever body a proxy or a CDN put on it", async () => {
const shapes: Array<[string, MockResponse]> = [
["the service's own body", { status: 404, body: { error: "not_found" } }],
["a proxy's HTML page", { status: 404, badJson: true }],
["an empty body", { status: 404 }],
["unrelated JSON", { status: 404, body: { message: "no route matched" } }],
["a body naming another code", { status: 404, body: { error: "internal" } }],
];
for (const [what, reply] of shapes) {
const { fn, calls } = fetchReturning(reply);
await assert.rejects(
() => waitForChange("gone", "open", fn, instantSleep),
(e: unknown) => e instanceof ApiError && e.code === "not_found" && e.status === 404, what,
);
assert.equal(calls.length, 1, `${what}: a 404 is terminal, not a failure to retry`);
}
});
apiTest("api: this client's own error words are never adopted FROM the wire", async () => {
for (const named of ["invalid_response", "unknown", "teapot"]) {
const { fn } = fetchReturning({ status: 400, body: { error: named } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === "unknown" && e.status === 400, named,
);
}
const { fn } = fetchReturning({ status: 400, body: { error: "bad_request" } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "xmr", codeHash: "h" }, fn),
(e: unknown) => e instanceof ApiError && e.code === "bad_request",
);
});
apiTest("api: a non-404 error response's body is drained rather than left unconsumed", async () => {
const { fn, consumed } = fetchReturning(
{ status: 500, body: { error: "internal" } },
{ status: 200, body: { status: "paid" } },
);
await waitForChange("i", "open", fn, instantSleep);
assert.equal(consumed[0], true);
});
apiTest("api: a 429 waits out the exact interval named by Retry-After, not the 1s backoff start", async () => {
const delays: number[] = [];
const { fn } = fetchReturning(
{ status: 429, body: { error: "rate_limited" }, headers: { "retry-after": "5" } },
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [5000]);
assert.equal(got.status, "paid");
});
apiTest("api: a 429's wait does not consume or advance the backoff ladder", async () => {
const delays: number[] = [];
const { fn } = fetchReturning(
{ status: 429, body: { error: "rate_limited" }, headers: { "retry-after": "5" } },
new Error("offline"),
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [5000, 1000]);
assert.equal(got.status, "paid");
});
apiTest("api: a 429 with a missing or unusable Retry-After falls back to the network-error backoff", async () => {
const cases: Array<Record<string, string> | undefined> = [
undefined, {}, { "retry-after": "" }, { "retry-after": "soon" }, { "retry-after": "0" }, { "retry-after": "-5" },
];
for (const headers of cases) {
const delays: number[] = [];
const { fn } = fetchReturning(
{ status: 429, body: { error: "rate_limited" }, headers },
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [1000], `headers=${JSON.stringify(headers)}`);
assert.equal(got.status, "paid");
}
});
apiTest("api: Retry-After is clamped to 300s in the waiting loop, not honoured verbatim", async () => {
const delays: number[] = [];
const { fn } = fetchReturning(
{ status: 429, body: { error: "rate_limited" }, headers: { "retry-after": "86400" } },
{ status: 200, body: { status: "paid" } },
);
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); });
assert.deepEqual(delays, [300_000]);
assert.equal(got.status, "paid");
});
apiTest("api: createInvoice's ApiError carries retryAfter from a 429's Retry-After, clamped to 300s, and omits it when absent", async () => {
const withHeader = fetchReturning({ status: 429, body: { error: "rate_limited" }, headers: { "retry-after": "60" } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "card", codeHash: "h" }, withHeader.fn),
(e: unknown) => e instanceof ApiError && e.code === "rate_limited" && e.retryAfter === 60,
);
const excessive = fetchReturning({ status: 429, body: { error: "rate_limited" }, headers: { "retry-after": "86400" } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "card", codeHash: "h" }, excessive.fn),
(e: unknown) => e instanceof ApiError && e.retryAfter === 300,
);
const withoutHeader = fetchReturning({ status: 429, body: { error: "rate_limited" } });
await assert.rejects(
() => createInvoice({ priceId: "p", method: "card", codeHash: "h" }, withoutHeader.fn),
(e: unknown) => e instanceof ApiError && e.code === "rate_limited" && e.retryAfter === undefined,
);
});
apiTest("api: three consecutive fast (<5s) same-status answers trip the anti-spin floor", async () => {
const delays: number[] = [];
const { fn, calls } = fetchReturning(
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "paid" } },
);
const now = () => 0;
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); }, undefined, now);
assert.deepEqual(delays, [1000]);
assert.equal(calls.length, 4);
assert.equal(got.status, "paid");
});
apiTest("api: a 1.1s hold-ignoring proxy still trips the anti-spin floor (the threshold is 5s, not 1s)", async () => {
const delays: number[] = [];
const { fn, calls } = fetchReturning(
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "paid" } },
);
let t = 0;
const now = () => { t += 1100; return t; };
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); }, undefined, now);
assert.deepEqual(delays, [1000]);
assert.equal(calls.length, 4);
assert.equal(got.status, "paid");
});
apiTest("api: a genuine ~30s same-status hold never trips the anti-spin floor", async () => {
const delays: number[] = [];
const { fn, calls } = fetchReturning(
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "open" } },
{ status: 200, body: { status: "paid" } },
);
let t = 0;
const now = () => { t += 30_000; return t; };
const got = await waitForChange("i", "open", fn, async (ms) => { delays.push(ms); }, undefined, now);
assert.deepEqual(delays, []);
assert.equal(calls.length, 6);
assert.equal(got.status, "paid");
});
apiTest("api: waitForChange rejects at once with AbortedError when the signal is already aborted, issuing no fetch", async () => {
const controller = new AbortController();
controller.abort();
const { fn, calls } = fetchReturning({ status: 200, body: { status: "paid" } });
await assert.rejects(
() => waitForChange("i", "open", fn, instantSleep, controller.signal),
(e: unknown) => e instanceof AbortedError,
);
assert.equal(calls.length, 0);
});
apiTest("api: an abort mid-flight rejects with AbortedError and issues no further fetch", async () => {
const controller = new AbortController();
let callCount = 0;
const fn = ((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => {
callCount++;
init?.signal?.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" })));
})) as unknown as typeof fetch;
const promise = waitForChange("i", "open", fn, instantSleep, controller.signal);
controller.abort();
await assert.rejects(() => promise, (e: unknown) => e instanceof AbortedError);
assert.equal(callCount, 1);
});
apiTest("api: an abort mid-sleep clears its pending timer — setTimeout/clearTimeout counts balance", async () => {
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
let setCount = 0;
let clearCount = 0;
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
setCount++;
return realSetTimeout(...args);
}) as typeof setTimeout;
globalThis.clearTimeout = ((...args: Parameters<typeof clearTimeout>) => {
clearCount++;
return realClearTimeout(...args);
}) as typeof clearTimeout;
try {
const controller = new AbortController();
const { fn, calls } = fetchReturning(new Error("offline"));
// No injected sleep, so the real setTimeout handle is exercised rather than a leak-free double.
const promise = waitForChange("i", "open", fn, undefined, controller.signal);
await new Promise((r) => setImmediate(r));
controller.abort();
await assert.rejects(() => promise, (e: unknown) => e instanceof AbortedError);
assert.equal(calls.length, 1);
assert.equal(setCount, 1);
assert.equal(clearCount, 1);
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
}
});
const SERVER = fileURLToPath(new URL("../../mock/server.py", import.meta.url));
const E2E_TIMEOUT_MS = 30_000;
// The server-side hold, long enough that the wait is parked on it, short enough to release the socket inside E2E_TIMEOUT_MS.
const HOLD_SECONDS = "5";
const READY_TIMEOUT_MS = 10_000;
const READY_POLL_MS = 25;
const PRE_SETTLE_MS = 150;
// From settle to waitForChange resolving; a fake hold cannot come in under this because the anti-spin floor sleeps first.
const WAKE_LIMIT_MS = 500;
function e2eTest(name: string, fn: () => Promise<void>): void {
test(name, { timeout: E2E_TIMEOUT_MS }, fn);
}
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
class MemoryStorage {
map = new Map<string, string>();
getItem(k: string) { return this.map.get(k) ?? null; }
setItem(k: string, v: string) { this.map.set(k, v); }
removeItem(k: string) { this.map.delete(k); }
}
// A kernel-chosen free port, since a fixed one can collide with a stray or concurrent server and pass green against the wrong one.
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const address = probe.address();
if (address === null || typeof address === "string") {
probe.close();
reject(new Error("freePort: the probe socket reported no port"));
return;
}
const { port } = address;
probe.close(() => resolve(port));
});
});
}
// Polls until the mock's own 404 body answers, so a connection accepted by something else on the port is not mistaken for ours.
async function ready(base: string, proc: ChildProcess): Promise<void> {
const deadline = Date.now() + READY_TIMEOUT_MS;
let last = "no attempt completed";
for (;;) {
if (proc.exitCode !== null || proc.signalCode !== null) {
throw new Error(`mock server exited before answering (code ${proc.exitCode}, signal ${proc.signalCode})`);
}
try {
const res = await fetch(`${base}/api/invoice/no-such-invoice`);
const body = (await res.json()) as { error?: string };
if (res.status === 404 && body.error === "not_found") return;
last = `answered ${res.status} ${JSON.stringify(body)}`;
} catch (e) {
last = String(e);
}
if (Date.now() >= deadline) throw new Error(`mock server never became ready on ${base}: ${last}`);
await delay(READY_POLL_MS);
}
}
function viaNetwork(base: string): typeof fetch {
return ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const url = typeof input === "string" && input.startsWith("/") ? `${base}${input}` : input;
// Keep-alive is off, or a pooled socket to the SIGKILLed mock would hold the event loop open past the last assertion.
const headers = { ...(init?.headers as Record<string, string> | undefined), connection: "close" };
return fetch(url, { ...init, headers });
}) as typeof fetch;
}
// Keeps the raw response bytes, since the parser drops fields and stringifying its return would prove nothing about the wire.
function recording(f: typeof fetch): { f: typeof fetch; bodies: string[] } {
const bodies: string[] = [];
const wrapped = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const res = await f(input, init);
bodies.push(await res.clone().text());
return res;
}) as typeof fetch;
return { f: wrapped, bodies };
}
async function control(f: typeof fetch, action: string, invoiceId: string): Promise<{ status: number; body: unknown }> {
const res = await f(`/control/${action}/${encodeURIComponent(invoiceId)}`, { method: "POST" });
return { status: res.status, body: await res.json() };
}
async function withServer(fn: (f: typeof fetch, bodies: string[]) => Promise<void>): Promise<void> {
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const proc = spawn("python3", [SERVER, "--port", String(port)], {
stdio: "ignore",
env: { ...process.env, MOCK_HOLD_SECONDS: process.env.MOCK_HOLD_SECONDS ?? HOLD_SECONDS },
});
try {
await ready(base, proc);
const { f, bodies } = recording(viaNetwork(base));
await fn(f, bodies);
} finally {
// The mock is SIGKILLed rather than SIGTERMed, or a thread parked in event.wait would keep the process and its port alive.
proc.kill("SIGKILL");
if (proc.exitCode === null && proc.signalCode === null) await once(proc, "exit");
}
}
e2eTest("e2e: a client that hangs up mid-hold is not an error", async () => {
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const proc = spawn("python3", [SERVER, "--port", String(port)], {
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, MOCK_HOLD_SECONDS: process.env.MOCK_HOLD_SECONDS ?? HOLD_SECONDS },
});
let stderr = "";
proc.stderr?.on("data", (chunk) => { stderr += String(chunk); });
try {
await ready(base, proc);
const f = viaNetwork(base);
const created = await createInvoice(
{ priceId: "price_supporter", method: "btc", codeHash: await hash(generate()) }, f);
const sock = connect(created.invoiceId ? port : port, "127.0.0.1");
await once(sock, "connect");
sock.write(`GET /api/invoice/${created.invoiceId}?wait=open HTTP/1.1\r\nHost: x\r\n\r\n`);
await delay(PRE_SETTLE_MS);
sock.destroy();
await delay(100);
assert.equal((await control(f, "settle", created.invoiceId)).status, 200);
await delay(400);
const after = await fetch(`${base}/api/invoice/${created.invoiceId}`);
assert.equal(after.status, 200);
assert.equal(((await after.json()) as { status: string }).status, "paid");
assert.ok(!/Traceback|BrokenPipeError|ConnectionResetError/.test(stderr),
`a dropped client was reported as a server error:\n${stderr.slice(0, 600)}`);
} finally {
proc.kill("SIGKILL");
if (proc.exitCode === null && proc.signalCode === null) await once(proc, "exit");
}
});
e2eTest("e2e: a purchase runs end to end, and settlement wakes the wait", async () => {
await withServer(async (f, bodies) => {
const store = new Store(new MemoryStorage());
const code = generate();
const codeHash = await hash(code);
const created = await createInvoice(
{ priceId: "price_legend", offerId: "offer_12m", method: "xmr", codeHash }, f);
assert.equal(created.amount, 42_000);
assert.equal(created.months, 12);
assert.equal(created.badgeType, "legend");
assert.equal(created.currency, "usd");
assert.equal(created.cryptoCurrency, "xmr");
assert.equal(created.cryptoAmount, "1.482");
assert.equal(created.clientSecret, undefined, "an xmr invoice carries no card secret");
assert.ok(created.invoiceId.length > 0);
store.saveOrder({
orderId: created.invoiceId, badgeType: created.badgeType,
months: created.months, createdAt: new Date().toISOString(), status: "open", code: display(code),
});
const waiting = waitForChange(created.invoiceId, "open", f);
let woken = false;
waiting.then(() => { woken = true; }, () => { woken = true; });
await delay(PRE_SETTLE_MS);
assert.equal(woken, false,
`the wait must still be pending while the invoice is open (it returned inside ${PRE_SETTLE_MS}ms)`);
const settleAt = Date.now();
const settle = await control(f, "settle", created.invoiceId);
assert.equal(settle.status, 200);
const settled = await waiting;
const wakeMs = Date.now() - settleAt;
assert.equal(settled.status, "paid");
assert.equal(settled.amountPaid, 42_000);
assert.equal(settled.cryptoAmountPaid, "1.482");
assert.ok(wakeMs < WAKE_LIMIT_MS,
`settlement must wake the hold rather than be found by the next poll (took ${wakeMs}ms)`);
store.saveOrder({ ...store.order(created.invoiceId)!, status: "paid" });
const orders = store.orders();
assert.equal(orders.length, 1);
const only = orders[0]!;
assert.equal(only.orderId, created.invoiceId);
assert.equal(only.status, "paid");
assert.equal(only.code, display(code), "settlement must not clear the stored code");
const recovered = normalise(only.code!);
assert.equal(recovered, code, "the stored code round-trips byte-identical");
assert.equal(checkChar(recovered!.slice(0, -1)), recovered!.slice(-1), "and still validates");
// Every response byte, since the service never has the code and must not echo the hash it was keyed by.
assert.ok(bodies.length >= 3, `expected the whole exchange to be recorded, saw ${bodies.length} responses`);
for (const body of bodies) {
assert.ok(!body.includes(code), `a response carried the code: ${body}`);
assert.ok(!body.includes(display(code)), `a response carried the displayed code: ${body}`);
assert.ok(!body.includes(codeHash), `a response carried the code hash: ${body}`);
}
});
});
e2eTest("e2e: a repeated code hash is refused with code_conflict, not merely refused", async () => {
await withServer(async (f) => {
const codeHash = await hash(generate());
const req = { priceId: "price_legend", method: "card", codeHash } as const;
const first = await createInvoice(req, f);
assert.ok(first.clientSecret!.startsWith("cs_test_"), "the first one is created normally");
await assert.rejects(
() => createInvoice(req, f),
(e: unknown) => {
assert.ok(e instanceof ApiError, `expected an ApiError, got ${String(e)}`);
assert.equal(e.code, "code_conflict");
assert.equal(e.status, 409);
return true;
});
});
});
e2eTest("e2e: an expired invoice reports what arrived before it closed", async () => {
await withServer(async (f) => {
const inv = await createInvoice(
{ priceId: "price_supporter", method: "xmr", codeHash: await hash(generate()) }, f);
assert.equal(inv.amount, 700);
assert.equal((await control(f, "partial", inv.invoiceId)).status, 200);
assert.equal((await control(f, "expire", inv.invoiceId)).status, 200);
const view = await waitForChange(inv.invoiceId, "open", f);
assert.equal(view.status, "expired");
assert.equal(view.amountPaid, 350, "half of 700 arrived");
assert.equal(view.cryptoAmountPaid, "0.734");
assert.equal(view.settledAt, undefined, "nothing settled");
});
});
@@ -0,0 +1,66 @@
// main.ts runs on import and a process gets one boot, so a second boot URL needs its own file.
import assert from "node:assert/strict";
import { headingOf, installPage, inViewOf, primaryOf, settle, timedTest } from "./boot.js";
import type { StubElement } from "./stub-dom.js";
const bootTest = timedTest(3000);
const page = installPage({ url: "/#/checkout" });
const { app, location, history, storage, fetches } = page;
await import("../src/main.js");
const inView = (): StubElement => inViewOf(app);
const heading = (): string => headingOf(inView());
bootTest("main: booting at #/checkout with nothing answered lands on the landing screen, not a blank order summary", () => {
assert.equal(heading(), "Support SimpleX");
assert.ok(!inView().textContent.includes("Check your order"));
assert.ok(!inView().textContent.includes("Pay "), "and no Pay button anywhere on it");
assert.equal(storage.getItem("sb.session.v1"), null, "nothing was invented to make it renderable");
assert.equal(fetches.length, 0, "and nothing was asked of the network");
});
bootTest("main: the URL is rewritten too, so Back still leaves the site", () => {
assert.equal(location.hash, "", "the landing screen carries no hash");
assert.equal(history.url, "/");
assert.equal(history.at, 0, "and it REPLACED the entry rather than adding one");
history.back();
assert.equal(history.left, true, "there is nothing behind the landing screen");
history.left = false;
});
bootTest("main: the wizard still works from there — the tier list answers, and the order summary is reachable", async () => {
inView().all("button.primary")[0]!.click();
assert.equal(heading(), "Choose your badge");
inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!.click();
primaryOf(inView())!.click();
assert.equal(heading(), "How long?");
inView().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!.click();
primaryOf(inView())!.click();
assert.equal(heading(), "Check your order");
assert.ok(inView().textContent.includes("Legend"), inView().textContent);
assert.ok(inView().textContent.includes("Pay $420.00 with "), "with a real total on a live button");
await settle();
});
bootTest("main: and #/checkout is reachable once the level HAS been answered", () => {
history.pushState(null, "", "#/checkout");
page.fire("popstate");
assert.equal(heading(), "Check your order");
assert.equal(location.hash, "#/checkout");
});
bootTest("main: nothing is left running once this file is done", async () => {
const before = fetches.length;
await settle(10);
assert.equal(fetches.length, before, "no loop is still issuing requests");
});
// A fetch called with an already-aborted signal never fires abort, so the stub must reject it or the request would hold until the test times out.
bootTest("boot: a request whose signal is already aborted rejects rather than holding", async () => {
const ctl = new AbortController();
ctl.abort();
const pageFetch = (globalThis as unknown as { window: { fetch: typeof fetch } }).window.fetch;
await assert.rejects(() => pageFetch("/api/invoice/never", { signal: ctl.signal }));
});
@@ -0,0 +1,40 @@
// An offline reload draws from the record, so an order left open with its destination draws a dead address and a QR for it.
import assert from "node:assert/strict";
import { headingOf, installPage, screenOf, settle, timedTest, until } from "./boot.js";
import { ADDRESS, HELD_CODE, NOW, openReply, ORDER_ID, seededStorage, storedOrder } from "./open-order.js";
import { CANCEL_INVOICE } from "../src/screens.js";
import { mock } from "node:test";
const cancelTest = timedTest(3000);
const storage = seededStorage();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: `/?order=${ORDER_ID}` });
const { app, chrome } = page;
page.respondWith(openReply);
await import("../src/main.js");
cancelTest("main: a cancel answered after the buyer left is still written to the record", async () => {
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen");
page.confirmAnswer(true);
// Nothing is armed, so the cancel holds on the wire.
screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE)!.click();
await settle();
chrome.all("button.menu-item").find((b) => b.textContent === "Buy a code")!.click();
assert.equal(headingOf(screenOf(app)), "Support SimpleX", "the buyer is starting a new purchase");
assert.ok(page.answerHeld({ status: 200, body: { status: "expired", amount: 42000, currency: "usd" } }, "/cancel"),
"the cancel is the request still holding");
await settle();
const after = storedOrder(storage)!;
assert.equal(after.status, "expired", `the invoice is dead and the record says so: ${JSON.stringify(after)}`);
for (const key of ["address", "cryptoAmount", "expiresAt"] as const) {
assert.equal(after[key], undefined, `${key} must go: an offline reload draws the record`);
}
assert.ok(!JSON.stringify(after).includes(ADDRESS), "and nothing anywhere still names the dead address");
assert.equal(after.code, HELD_CODE, "the code is the one thing this browser cannot get back");
});
@@ -0,0 +1,46 @@
// A payment can land during the cancel's round trip, so the answer is expired with a figure on it, and reading the status alone would send the buyer to a new purchase with money stuck.
import { mock } from "node:test";
import assert from "node:assert/strict";
import { headingOf, installPage, screenOf, timedTest, until } from "./boot.js";
import { ADDRESS, NOW, openReply, ORDER_ID, seededStorage } from "./open-order.js";
import { CANCEL_INVOICE } from "../src/screens.js";
const raceTest = timedTest(3000);
const storage = seededStorage();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: `/?order=${ORDER_ID}` });
const { app } = page;
page.respondWith(openReply);
await import("../src/main.js");
const cancelledWithPayment = {
status: 200,
body: {
status: "expired", badgeType: "legend", months: 12,
amount: 42000, currency: "usd",
cryptoAmountPaid: "0.741", cryptoAmountDue: "0.741", paidInFull: false,
},
} as const;
raceTest("main: a cancel that raced a payment leaves the buyer on the order", async () => {
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen");
page.confirmAnswer(true);
page.respondWith(cancelledWithPayment);
screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE)!.click();
// The restart's first read decides what the buyer sees, and failing it makes the loop fall back on the record, which still names the address.
page.respondWith({ status: 500, body: { error: "internal" } });
await until(() => headingOf(screenOf(app)) === "This invoice expired",
"the closed-window screen, not the payment screen it replaced");
assert.ok(!screenOf(app).textContent.includes(ADDRESS),
`no dead address is drawn: ${screenOf(app).textContent.slice(0, 160)}`);
assert.ok(!screenOf(app).textContent.includes("Choose your badge"),
"and not the landing screen either");
assert.ok(screenOf(app).textContent.includes(ORDER_ID), "the reference support reconciles against");
});
@@ -0,0 +1,83 @@
import { mock } from "node:test";
import assert from "node:assert/strict";
import { headingOf, installPage, screenOf, settle, timedTest, until } from "./boot.js";
import { type StubElement } from "./stub-dom.js";
import { ADDRESS, NOW, openReply, ORDER_ID, seededStorage, storedOrder } from "./open-order.js";
import { CANCEL_INVOICE, CANCEL_HAS_FUNDS } from "../src/screens.js";
const refusalTest = timedTest(3000);
const storage = seededStorage();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: `/?order=${ORDER_ID}` });
const { app, fetches } = page;
page.respondWith(openReply);
await import("../src/main.js");
function cancelControl(): StubElement | undefined {
return screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE);
}
refusalTest("main: a funded refusal keeps the order's own screen, not a stripped-down one", async () => {
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen");
page.confirmAnswer(true);
page.respondWith({ status: 409, body: { error: "funded" } });
cancelControl()!.click();
// The restarted watch reads here, and without the previous loop's options it would draw this order as a fresh one.
page.respondWith(openReply);
await until(() => screenOf(app).textContent.includes(CANCEL_HAS_FUNDS), "money is riding on it");
assert.ok(screenOf(app).textContent.includes("Started"),
"the repaint draws the order's own screen — the line saying how long it has been open — not a stripped-down one");
});
refusalTest("main: the reason does not follow the buyer to an order they did not cancel", async () => {
assert.ok(screenOf(app).textContent.includes(CANCEL_HAS_FUNDS), "the reason is still on the order it belongs to");
history.pushState(null, "", "/");
page.fire("popstate");
await settle();
// The reply is queued before the navigation so it is ready when the order screen mounts and reads.
page.respondWith(openReply);
history.pushState(null, "", `?order=${ORDER_ID}`);
page.fire("popstate");
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen again");
assert.ok(!screenOf(app).textContent.includes(CANCEL_HAS_FUNDS),
`a visit with no cancel behind it draws no refusal: ${screenOf(app).textContent.slice(0, 160)}`);
});
refusalTest("main: a repaint mid-cancel does not arm a second one", async () => {
const before = fetches.filter((f) => f.url.endsWith("/cancel")).length;
page.confirmAnswer(true);
page.respondWith({ status: 409, body: { error: "funded" } });
// No await after this click, so the POST stays on the wire, which is the window a second click must be refused in.
screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE)!.click();
// The online event redraws the panel, giving a Cancel button whose disabled attribute is gone.
page.fire("online");
screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE)?.click();
page.respondWith(openReply);
await settle(10);
assert.equal(fetches.filter((f) => f.url.endsWith("/cancel")).length - before, 1,
"the second click is refused while the first is still on the wire");
});
refusalTest("main: a not_open refusal takes the destination with it", async () => {
page.confirmAnswer(true);
page.respondWith({ status: 409, body: { error: "not_open" } });
cancelControl()!.click();
await settle();
const after = storedOrder(storage);
assert.notEqual(after, undefined, "the order is still known to this browser");
for (const key of ["address", "cryptoAmount", "cryptoCurrency", "expiresAt"] as const) {
assert.equal(after?.[key], undefined,
`${key} must go: the service has said the invoice is closed, so nothing may be sent to it`);
}
assert.ok(!JSON.stringify(after).includes(ADDRESS),
"and an offline reload cannot draw the dead address back out of the record");
});
@@ -0,0 +1,38 @@
// A reload draws the record whenever the first read fails, so a destination left on a not_open order is a payable screen for an invoice nothing can reach.
import assert from "node:assert/strict";
import { headingOf, installPage, screenOf, settle, timedTest, until } from "./boot.js";
import { ADDRESS, NOW, openReply, ORDER_ID, seededStorage, storedOrder } from "./open-order.js";
import { CANCEL_INVOICE } from "../src/screens.js";
import { mock } from "node:test";
const cancelTest = timedTest(3000);
const storage = seededStorage();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: `/?order=${ORDER_ID}` });
const { app, chrome } = page;
page.respondWith(openReply);
await import("../src/main.js");
cancelTest("main: a not_open refused after the buyer left still takes the destination", async () => {
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen");
page.confirmAnswer(true);
// Nothing is armed, so the cancel holds on the wire.
screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE)!.click();
await settle();
chrome.all("button.menu-item").find((b) => b.textContent === "Buy a code")!.click();
assert.equal(headingOf(screenOf(app)), "Support SimpleX", "the buyer is starting a new purchase");
assert.ok(page.answerHeld({ status: 409, body: { error: "not_open" } }, "/cancel"),
"the cancel is the request still holding");
await settle();
const after = storedOrder(storage)!;
for (const key of ["address", "cryptoAmount", "cryptoCurrency", "expiresAt"] as const) {
assert.equal(after[key], undefined, `${key} must go: nothing can reach that address now`);
}
assert.ok(!JSON.stringify(after).includes(ADDRESS), `and nothing still names it: ${JSON.stringify(after)}`);
});
@@ -0,0 +1,45 @@
// A reopened order builds its watch from the record before the cancel, so the cancel's answer must reach the screen and not only the store.
import assert from "node:assert/strict";
import { headingOf, installPage, screenOf, settle, timedTest, until } from "./boot.js";
import { ADDRESS, NOW, openReply, ORDER_ID, seededStorage, storedOrder } from "./open-order.js";
import { CANCEL_INVOICE } from "../src/screens.js";
import { mock } from "node:test";
const cancelTest = timedTest(4000);
const storage = seededStorage();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: `/?order=${ORDER_ID}` });
const { app, chrome } = page;
page.respondWith(openReply);
await import("../src/main.js");
cancelTest("main: an accepted cancel takes the payable screen down, not just the record", async () => {
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen");
page.confirmAnswer(true);
// The cancel holds on the wire while the buyer goes looking.
screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE)!.click();
await settle();
chrome.all("button.menu-item").find((b) => b.textContent === "Your codes")!.click();
await settle();
page.respondWith({ status: 500, body: { error: "internal" } });
const open = screenOf(app).all("a").find((a) => a.textContent === "Open");
assert.ok(open !== undefined, "the list offers the order");
open.click({ button: 0 });
await until(() => screenOf(app).serialize().includes(ADDRESS), "the reopened payment screen");
assert.ok(page.answerHeld({ status: 200, body: { status: "expired", amount: 42000, currency: "usd" } }, "/cancel"),
"the cancel is the request still holding");
await settle(30);
const after = storedOrder(storage)!;
assert.equal(after.status, "expired", "the record knows the invoice is dead");
assert.equal(after.address, undefined, "and holds nothing payable");
const painted = screenOf(app).serialize();
assert.ok(!painted.includes(ADDRESS),
`the address of an invoice this buyer cancelled is still on screen: ${headingOf(screenOf(app))}`);
});
@@ -0,0 +1,92 @@
import { mock } from "node:test";
import assert from "node:assert/strict";
import { headingOf, installPage, screenOf, settle, timedTest, until } from "./boot.js";
import { type StubElement } from "./stub-dom.js";
import { ADDRESS, HELD_CODE, NOW, openReply, ORDER_ID, seededStorage, storedOrder } from "./open-order.js";
import { CANCEL_INVOICE, CANCEL_FAILED, CANCEL_HAS_FUNDS, CANCEL_NOT_OPEN, CANCEL_STILL_OPEN } from "../src/screens.js";
const cancelTest = timedTest(3000);
const storage = seededStorage();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: `/?order=${ORDER_ID}` });
const { app, fetches } = page;
// This answers the watch's first read; the ?wait= after it holds, as a real one does.
page.respondWith(openReply);
await import("../src/main.js");
function cancelControl(): StubElement | undefined {
return screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE);
}
cancelTest("main: a cancel the service refuses says so and keeps watching the order", async () => {
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen");
page.confirmAnswer(true);
page.respondWith({ status: 500, body: { error: "internal" } });
const before = fetches.length;
cancelControl()?.click();
await settle();
await until(() => screenOf(app).textContent.includes(CANCEL_FAILED), "the failure is on screen");
// A count alone is satisfied by the cancel POST, so this checks for the read the restarted watch makes.
const after = fetches.slice(before).map((f) => f.url);
assert.ok(after.some((u) => u.startsWith(`/api/invoice/${ORDER_ID}`) && !u.endsWith("/cancel")),
`the watch must be reading again, not left dead: ${JSON.stringify(after)}`);
});
cancelTest("main: each cancel refusal says which of the two it is", async () => {
page.confirmAnswer(true);
page.respondWith({ status: 409, body: { error: "funded" } });
cancelControl()?.click();
await settle();
await until(() => screenOf(app).textContent.includes(CANCEL_HAS_FUNDS), "money is riding on it");
assert.ok(!screenOf(app).textContent.includes(CANCEL_FAILED), "never the generic wording");
// The reason must outlive the repaint the restarted watch makes, or the buyer never reads it.
page.setOffline(true);
await settle();
page.setOffline(false);
await settle();
assert.ok(screenOf(app).textContent.includes(CANCEL_HAS_FUNDS),
`a repaint must not take the reason away: ${screenOf(app).textContent.slice(0, 120)}`);
page.confirmAnswer(true);
page.respondWith({ status: 409, body: { error: "not_open" } });
cancelControl()?.click();
await settle();
await until(() => screenOf(app).textContent.includes(CANCEL_NOT_OPEN), "the invoice is simply gone");
assert.ok(!screenOf(app).textContent.includes(CANCEL_HAS_FUNDS),
"an empty expired invoice must not be described as holding a payment");
});
cancelTest("main: a cancel answered 200 but still open is not a done deal", async () => {
page.confirmAnswer(true);
// An open answer with nothing on it should not happen, but if it does the address is dead and 200 alone must not read as cancelled.
page.respondWith(openReply);
cancelControl()?.click();
await settle();
await until(() => screenOf(app).textContent.includes(CANCEL_STILL_OPEN),
"the buyer is told, not sent off to start a new purchase");
});
cancelTest("main: a cancelled order keeps its code and loses everything payable", async () => {
page.confirmAnswer(true);
page.respondWith({ status: 200, body: { status: "expired", amount: 42000, currency: "usd" } });
cancelControl()!.click();
await until(() => storedOrder(storage)!.status === "expired", "the answer is applied to the record");
const after = storedOrder(storage)!;
assert.equal(after.canceled, true, "the buyer's own cancel is recorded, so Your codes reads it as canceled");
assert.equal(after.code, HELD_CODE, "the code is the one thing this browser cannot get back");
for (const key of ["address", "cryptoAmount", "cryptoCurrency", "expiresAt"] as const) {
assert.equal(after[key], undefined, `${key} must go: the address stops accepting payment`);
}
assert.ok(!JSON.stringify(after).includes(ADDRESS), "and nothing anywhere still names it");
await until(() => headingOf(screenOf(app)) === "Support SimpleX",
"the landing screen, ready for a new purchase");
});
@@ -0,0 +1,91 @@
// A PaymentIntent is only cancelable before it confirms, so the card Cancel is inert while a confirm is in flight.
import { mock } from "node:test";
import assert from "node:assert/strict";
import { headingOf, installPage, inViewOf, primaryOf, screenOf, settle, timedTest, until } from "./boot.js";
import { MemStorage, StubElement } from "./stub-dom.js";
import { CANCEL_INVOICE } from "../src/screens.js";
const cardCancelTest = timedTest(5000);
const NOW = Date.parse("2026-08-28T12:00:00Z");
const PUBLISHABLE_KEY = "pk_test_card_cancel";
const CLIENT_SECRET = "cs_test_cancel";
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const storage = new MemStorage();
const page = installPage({ storage });
const { app } = page;
const keyMeta = new StubElement("meta");
keyMeta.setAttribute("id", "stripe-publishable-key");
keyMeta.setAttribute("name", "stripe-publishable-key");
keyMeta.setAttribute("content", PUBLISHABLE_KEY);
page.document.byId.set("stripe-publishable-key", keyMeta);
// The confirm is released by hand so the in-flight window stays open long enough to press Cancel inside it.
let releaseConfirm: (() => void) | null = null;
(globalThis as unknown as { window: Record<string, unknown> }).window.Stripe = () => ({
elements: () => ({ create: () => ({ mount: () => {}, destroy: () => {} }) }),
confirmPayment: () => new Promise((res) => { releaseConfirm = () => res({ paymentIntent: { status: "succeeded" } }); }),
});
await import("../src/main.js");
const heading = (): string => headingOf(screenOf(app));
const inView = (): StubElement => inViewOf(app);
const cancelBtn = (): StubElement | undefined => screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE);
const payBtn = (): StubElement => screenOf(app).all("button.primary").find((b) => b.textContent.startsWith("Pay"))!;
const stripeTag = (): StubElement =>
page.document.head.children.find(
(c): c is StubElement => c instanceof StubElement && (c.getAttribute("src") ?? "").includes("js.stripe.com"),
)!;
const storedOrder = (id: string): Record<string, unknown> | undefined =>
(JSON.parse(storage.m.get("sb.orders.v1") ?? "[]") as Array<Record<string, unknown>>).find((o) => o.orderId === id);
async function walkToMountedCardForm(invoiceId: string): Promise<void> {
primaryOf(inView())!.click();
inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click();
primaryOf(inView())!.click();
inView().all("button.choice").find((c) => c.textContent.startsWith("1 month"))!.click();
primaryOf(inView())!.click();
inView().all("button.choice.method").find((b) => b.textContent.startsWith("Card"))!.click();
page.respondWith({ status: 200, body: {
invoiceId, badgeType: "supporter", months: 1,
amount: 700, currency: "usd", expiresAt: "2126-08-28T12:58:12Z", clientSecret: CLIENT_SECRET,
} });
inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click();
await until(() => heading() === "Pay by card", `the card form, not ${heading()}`);
stripeTag().dispatch("load");
await settle();
}
cardCancelTest("main: the card form offers Cancel, and a clean cancel marks the order canceled", async () => {
await walkToMountedCardForm("inv_card_c1");
assert.ok(cancelBtn() !== undefined, "the card form offers Cancel, the same control the crypto screen shows");
page.confirmAnswer(true);
page.respondWith({ status: 200, body: { status: "expired", amount: 700, currency: "usd" } });
cancelBtn()!.click();
await until(() => heading() === "Support SimpleX", "a clean cancel lands on the landing screen");
assert.equal(storedOrder("inv_card_c1")?.canceled, true,
"the order is marked canceled, so Your codes reads it as canceled, not expired");
});
cardCancelTest("main: the card Cancel is inert once a confirm is in flight", async () => {
await walkToMountedCardForm("inv_card_c2");
assert.ok(!payBtn().hasAttribute("disabled"), "the Element mounted, so Pay is live");
payBtn().click();
await settle();
assert.equal(heading(), "Pay by card", "the form stays put while the confirm is in flight");
const before = page.fetches.length;
page.confirmAnswer(true);
cancelBtn()!.click();
await settle();
assert.equal(heading(), "Pay by card", "canceling did nothing: a confirming intent is not cancelable");
assert.equal(page.fetches.slice(before).filter((f) => f.url.endsWith("/cancel")).length, 0,
"and no cancel request was sent");
releaseConfirm!();
await until(() => heading() === "Payment received", "the confirm resolves to the confirming screen");
});
@@ -0,0 +1,91 @@
// Every stored row holds a code, so saveOrder can evict nothing and the new order is never written.
import { mock } from "node:test";
import assert from "node:assert/strict";
import { headingOf, installPage, inViewOf, primaryOf, screenOf, settle, timedTest, until } from "./boot.js";
import { MemStorage, StubElement } from "./stub-dom.js";
const capTest = timedTest(5000);
const NOW = Date.parse("2026-08-28T12:00:00Z");
const PUBLISHABLE_KEY = "pk_test_full_store";
const CLIENT_SECRET = "cs_test_abc";
const full = new MemStorage();
full.m.set("sb.orders.v1", JSON.stringify(
Array.from({ length: 50 }, (_, i) => ({
orderId: `inv_old_${i}`, badgeType: "supporter", months: 1,
createdAt: new Date(NOW - (i + 2) * 86_400_000).toISOString(), status: "paid", paidInFull: true,
code: `SB-OLD${String(i).padStart(2, "0")}-YGQTM-PUYZ9-2TUXP`, amount: 700, currency: "usd",
})),
));
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage: full });
const { app } = page;
// The publishable key makes the card path use the real Payment Element, not a stand-in.
const keyMeta = new StubElement("meta");
keyMeta.setAttribute("id", "stripe-publishable-key");
keyMeta.setAttribute("name", "stripe-publishable-key");
keyMeta.setAttribute("content", PUBLISHABLE_KEY);
page.document.byId.set("stripe-publishable-key", keyMeta);
(globalThis as unknown as { window: Record<string, unknown> }).window.Stripe = (key: string) => {
assert.equal(key, PUBLISHABLE_KEY, "the page's own configured key");
return {
elements: (options: { clientSecret: string }) => {
assert.equal(options.clientSecret, CLIENT_SECRET);
return { create: () => ({ mount: () => {}, destroy: () => {} }) };
},
confirmPayment: async () => ({ paymentIntent: { status: "succeeded" } }),
};
};
await import("../src/main.js");
const heading = (): string => headingOf(screenOf(app));
const inView = (): StubElement => inViewOf(app);
const stripeTag = (): StubElement =>
page.document.head.children.find(
(c): c is StubElement => c instanceof StubElement && (c.getAttribute("src") ?? "").includes("js.stripe.com"),
)!;
capTest("main: a card code survives the confirm when the orders list is full", async () => {
primaryOf(inView())!.click();
inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click();
primaryOf(inView())!.click();
inView().all("button.choice").find((c) => c.textContent.startsWith("1 month"))!.click();
primaryOf(inView())!.click();
assert.equal(headingOf(inView()), "Check your order");
assert.ok(inView().textContent.includes("This browser cannot save anything new right now."),
"the store is full, and the summary says so before the money");
inView().all("button.choice.method").find((b) => b.textContent.startsWith("Card"))!.click();
page.respondWith({ status: 200, body: {
invoiceId: "inv_card", badgeType: "supporter", months: 1,
amount: 700, currency: "usd", expiresAt: "2026-08-28T12:58:12Z",
clientSecret: CLIENT_SECRET,
} });
inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click();
await until(() => heading() === "Pay by card", `the card form, not ${heading()}`);
const codeShape = /SB-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}/;
assert.ok(!codeShape.test(screenOf(app).serialize()), "no code is on an unpaid screen");
stripeTag().dispatch("load");
await settle();
const pay = screenOf(app).all("button.primary").find((b) => b.textContent.startsWith("Pay"))!;
assert.ok(!pay.hasAttribute("disabled"), "the Element mounted, so Pay is live");
pay.click();
await until(() => heading() === "Payment received", `the confirming screen, not ${heading()}`);
page.answerHeld({ status: 200, body: {
status: "paid", badgeType: "supporter", months: 1,
amount: 700, currency: "usd", paidInFull: true, settledAt: "2026-08-28T12:05:00Z",
} }, "/api/invoice/inv_card");
await settle();
const painted = screenOf(app).serialize();
assert.equal(heading(), "Paid. Here is your code.",
`the code screen, not "${heading()}": ${painted.slice(0, 400)}`);
assert.ok(codeShape.test(painted), `and the code is on it: ${painted.slice(0, 400)}`);
});
@@ -0,0 +1,46 @@
// A card confirm redirects the whole page, so sb.cardReturn remembers the order before the redirect and the frame resumes it on the next load with no URL marker.
// The buyer paid the older of two unpaid orders, so resuming the newest would pick the wrong one.
import { mock } from "node:test";
import assert from "node:assert/strict";
import { headingOf, installPage, screenOf, timedTest, until } from "./boot.js";
import { MemStorage } from "./stub-dom.js";
const returnTest = timedTest(3000);
const NOW = Date.parse("2026-08-28T12:00:00Z");
const CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP";
const codeShape = /SB-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}/;
const storage = new MemStorage();
storage.setItem("sb.orders.v1", JSON.stringify([
{
orderId: "inv_newer", badgeType: "legend", months: 12,
createdAt: new Date(NOW - 10_000).toISOString(), status: "open",
address: "48HqK2Xmv", cryptoAmount: "1.4", cryptoCurrency: "xmr", amount: 42000, currency: "usd",
},
{
orderId: "inv_card", badgeType: "supporter", months: 1,
createdAt: new Date(NOW - 60_000).toISOString(), status: "paid", paidInFull: true,
submitted: true, code: CODE, amount: 700, currency: "usd", settledAt: "2026-08-28T11:59:50Z",
},
]));
storage.setItem("sb.cardReturn.v1", JSON.stringify({ orderId: "inv_card", at: NOW - 5_000 }));
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: "/?payment_intent=pi_1&redirect_status=succeeded" });
const { app } = page;
await import("../src/main.js");
const heading = (): string => headingOf(screenOf(app));
returnTest("main: a card return resumes the exact order it was for, not the newest, and shows the code", async () => {
await until(() => heading() === "Paid. Here is your code.", `the code screen, not ${heading()}`);
assert.ok(codeShape.test(screenOf(app).serialize()), "the code the buyer paid for is on it");
assert.ok(screenOf(app).serialize().includes(CODE), "and it is the code of inv_card, the order that was paid");
assert.ok(!screenOf(app).serialize().includes("48HqK2Xmv"), "not the newer open order's address");
});
returnTest("main: the return is spent — the URL is the order and the remembered id is cleared", () => {
assert.equal(page.location.search, "?order=inv_card", "the URL is normalised to the order resumed");
assert.equal(page.storage.getItem("sb.cardReturn.v1"), null,
"the remembered order is consumed, so a later plain load never re-resumes it");
});
@@ -0,0 +1,34 @@
// The cancel's answer is a write, so it would put back a record the buyer asked to forget, without its code.
import { mock } from "node:test";
import assert from "node:assert/strict";
import { forgetControl, headingOf, installPage, screenOf, settle, timedTest, until } from "./boot.js";
import { NOW, openReply, ORDER_ID, seededStorage } from "./open-order.js";
import { CANCEL_INVOICE } from "../src/screens.js";
const raceTest = timedTest(3000);
const storage = seededStorage();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: `/?order=${ORDER_ID}` });
const { app } = page;
page.respondWith(openReply);
await import("../src/main.js");
raceTest("main: a cancel answered after the wipe is not written back", async () => {
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen");
page.confirmAnswer(true);
page.respondWith({ status: 200, body: { status: "expired", amount: 42000, currency: "usd" } });
screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE)!.click();
// There is no settle here, because the cancel is on the wire while the wipe lands.
forgetControl(page)!.click();
assert.equal(storage.getItem("sb.orders.v1"), null, "the wipe itself is immediate");
await settle(10);
assert.equal(storage.getItem("sb.orders.v1"), null,
"and the cancelled invoice does not come back as a row the buyer cannot remove");
});
@@ -0,0 +1,35 @@
// A refused cancel restarts the watch, and a watch started over a just-wiped store would write the forgotten order back on its first read.
import { mock } from "node:test";
import assert from "node:assert/strict";
import { forgetControl, headingOf, installPage, screenOf, settle, timedTest, until } from "./boot.js";
import { NOW, openReply, ORDER_ID, seededStorage } from "./open-order.js";
import { CANCEL_INVOICE } from "../src/screens.js";
const refusedTest = timedTest(3000);
const storage = seededStorage();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW });
const page = installPage({ storage, url: `/?order=${ORDER_ID}` });
const { app } = page;
page.respondWith(openReply);
await import("../src/main.js");
refusedTest("main: a refused cancel answered after the wipe is not written back", async () => {
await until(() => headingOf(screenOf(app)).startsWith("Send"), "the payment screen");
page.confirmAnswer(true);
page.respondWith({ status: 409, body: { error: "funded" } });
screenOf(app).all("button").find((b) => b.textContent === CANCEL_INVOICE)!.click();
// There is no settle here, because the cancel is on the wire while the wipe lands.
forgetControl(page)!.click();
assert.equal(storage.getItem("sb.orders.v1"), null, "the wipe itself is immediate");
page.respondWith(openReply);
await settle(10);
assert.equal(storage.getItem("sb.orders.v1"), null,
"the order the buyer erased does not come back, with its address, as a row they cannot remove");
});
@@ -0,0 +1,30 @@
// renderCodes takes the root, so without its hash guard a late refresh paints the list over whatever screen the buyer moved to.
import assert from "node:assert/strict";
import { headingOf, inViewOf, installPage, settle, timedTest, until } from "./boot.js";
import { openReply, ORDER_ID, seededStorage } from "./open-order.js";
const navTest = timedTest(3000);
const page = installPage({ storage: seededStorage(), url: `/?order=${ORDER_ID}` });
const { app, history, location } = page;
page.respondWith(openReply);
await import("../src/main.js");
const inView = (): ReturnType<typeof inViewOf> => inViewOf(app);
navTest("main: a history refresh landing after the buyer moved on does not take the screen", async () => {
await until(() => headingOf(inView()).startsWith("Send"), "the payment screen");
page.respondWith(openReply);
page.chrome.all("button.menu-item").find((b) => b.textContent === "Your codes")!.click();
assert.equal(headingOf(inView()), "Your codes", "the list is drawn from the store at once");
page.respondWith(openReply);
history.pushState(null, "", `/?order=${ORDER_ID}`);
page.fire("popstate");
assert.equal(location.hash, "", "the hash the guard reads has moved off the list");
await settle(10);
assert.equal(headingOf(inView()), "Send 1.482 XMR",
`the refresh must not put the list back over the order: ${headingOf(inView())}`);
});
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { headingOf, inViewOf, installPage, primaryOf, timedTest } from "./boot.js";
import { MemStorage } from "./stub-dom.js";
import { NOT_KEPT_TITLE } from "../src/screens.js";
const noStorageTest = timedTest(3000);
class RefusingStorage extends MemStorage {
override setItem(): void { throw new Error("SecurityError: the operation is insecure"); }
}
const page = installPage({ storage: new RefusingStorage() });
const { app } = page;
await import("../src/main.js");
const inView = (): ReturnType<typeof inViewOf> => inViewOf(app);
noStorageTest("main: the wizard still walks when every write is refused", () => {
primaryOf(inView())!.click();
assert.equal(headingOf(inView()), "Choose your badge");
inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!.click();
// The click rebuilds the panel, so the node clicked above is detached and must be re-queried.
const chosen = inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!;
assert.equal(chosen.getAttribute("aria-pressed"), "true", "the choice is remembered in this session");
primaryOf(inView())!.click();
assert.equal(headingOf(inView()), "How long?");
inView().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!.click();
primaryOf(inView())!.click();
assert.equal(headingOf(inView()), "Check your order");
assert.ok(inView().textContent.includes(NOT_KEPT_TITLE),
`the warning belongs above the Pay button: ${inView().textContent.slice(0, 200)}`);
assert.ok(inView().all("button").some((b) => b.textContent.startsWith("Pay ")),
"and it is a warning, not a refusal");
});
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more