mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 08:37:30 +00:00
904 lines
51 KiB
Markdown
904 lines
51 KiB
Markdown
# SimpleX store service
|
||
|
||
`simplex-store-service` runs the whole store 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 `store_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 `store_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` in its code table,
|
||
- does not accept contact requests: the address is for RPC only,
|
||
- in service mode with `[group]` in the ini, manages one SimpleX group and serves `/issue`, `/bulk`
|
||
and `/revoke` in it (see [Issuing codes](#issuing-codes)),
|
||
- in service mode with `--service-config`, also serves the built web app (`npm run build` in `web/`) and, on the hosts in `names_hosts`, the names site (see [Deployment](#deployment)), `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 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
|
||
|
||
Build prerequisites and the general contribution flow are in [`docs/CONTRIBUTING.md`](../../docs/CONTRIBUTING.md).
|
||
|
||
```
|
||
cabal build exe:simplex-store-service
|
||
```
|
||
|
||
## Run
|
||
|
||
```
|
||
simplex-store-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`). This mode is the chat/RPC side and the `//` commands
|
||
below: it starts no web listener and no poller, and serves no group commands, whatever
|
||
`--service-config` says. It still updates a code's group message when the code is redeemed or
|
||
revoked.
|
||
- `--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`).
|
||
- `--issuer-secret SECRET` — the issuer secret from `simplex-chat badge keygen`.
|
||
|
||
The service checks the secret against the configured public key at that index and refuses to start
|
||
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 `store_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: `store_service.ini` is gitignored and already holds the
|
||
provider API keys and webhook secrets.
|
||
|
||
Other options:
|
||
|
||
- `--service-config INI_FILE`: path to `store_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-store-service --help`
|
||
for the complete list.
|
||
|
||
### Running the web checkout
|
||
|
||
`store_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), an optional `[group]` section (omitting it
|
||
turns off the group's commands, though a group created earlier still has its code messages updated),
|
||
the poll cadence, and optional `[names]` and `[chain]` sections for the names store (omitting both
|
||
turns it off), whose `[chain] service_key` is [the gas key](#the-gas-key).
|
||
`store_service.ini.example` is the committed template; `store_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-store-service/web && npm install && npm run build && cd ../../..
|
||
cp apps/simplex-store-service/store_service.ini.example apps/simplex-store-service/store_service.ini
|
||
cabal run simplex-store-service -- \
|
||
--issuer-key-idx IDX --issuer-secret SECRET \
|
||
--service-config apps/simplex-store-service/store_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.
|
||
|
||
**Do not set `speed_policy = HighSpeed` when names are sold** (`[names]` configured). Under it
|
||
BTCPay can mark a payment `Settled` with no confirmation, so the service never credits crypto that
|
||
reaches a name invoice after the invoice closed: the money stays pending, the poller alerts about it
|
||
once it is older than its read window (about 72 hours), and support settles it by hand. The service
|
||
judges each invoice by the policy it was created with, so changing the setting later credits nothing
|
||
that arrived under `HighSpeed`. With `MediumSpeed` (the template's value) or a slower policy, late
|
||
money is credited to the order's balance once confirmed, at the lower of the invoice's rate and the
|
||
rate when it is credited.
|
||
|
||
Give the service a BTCPay store of its own, and a Stripe account of its own if card payments are
|
||
on. The poller lists every invoice and payment intent of the last three days or so, and one it did
|
||
not create and cannot read is reported in a warning once an hour until it falls out of that window.
|
||
|
||
### 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. The names site's shell carries the
|
||
same meta without the `id`, and is filled the same way. 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
|
||
```
|
||
|
||
The full policies of both sites are in [Caddy](#caddy).
|
||
|
||
**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`.
|
||
|
||
## Deployment
|
||
|
||
`scripts/store-service/` holds the production deployment: the image (`Dockerfile`, built from
|
||
the repository root), `compose.yml`, the ini template `store_service.ini.example` and the egress
|
||
rule `egress.sh`. The service runs with `+client_postgres` and host networking; one Caddy serves
|
||
both sites from the folders the service exports at boot and proxies the API to it.
|
||
|
||
### Upgrading from the badge service
|
||
|
||
The rename to StoreService changed what the operator runs:
|
||
|
||
| Before | After |
|
||
|---|---|
|
||
| `scripts/badge-service/` | `scripts/store-service/` |
|
||
| compose service `badge-service`, image `simplex-badge-service` | compose service `store-service`, image `simplex-store-service` |
|
||
| `badge_service.ini`, mounted at `/etc/simplex-badge-service/badge_service.ini` | `store_service.ini`, mounted at `/etc/simplex-store-service/store_service.ini` |
|
||
| the binary `simplex-badge-service` | `simplex-store-service` |
|
||
|
||
Move `badge_service.ini` to `scripts/store-service/store_service.ini`, and point Caddy's badges
|
||
root at `scripts/store-service/web`, which the service fills at boot. Then start the new service
|
||
with
|
||
|
||
```
|
||
docker compose up -d --build --remove-orphans
|
||
```
|
||
|
||
from `scripts/store-service/`. Without `--remove-orphans` the old `badge-service` container keeps
|
||
running beside the new one, on the same port and database. The database `badge_service`,
|
||
`BADGE_DB_CONN`, the `sx_badge_service_` tables and the chat database prefix
|
||
`simplex_badge_service` are unchanged.
|
||
|
||
### Two sites
|
||
|
||
The image carries the badges build at `/srv/web` (`static_dir`). The names site (simplex.domains)
|
||
is configured beside it in `[listener]`. The image does not build the names site yet, so mount a
|
||
names build into the container for `names_static_dir` until it does.
|
||
|
||
- `names_static_dir`: the names build, the directory `npm run build` writes in the names webapp
|
||
(`dist/`);
|
||
- `names_export_dir`: where the service writes the names site at boot, with the Stripe
|
||
publishable key in its shell, as `webapp_export_dir` is for the badges site. `compose.yml`
|
||
bind-mounts `./names` there (`/srv/names-export`), and `./web` at `/srv/webapp`;
|
||
- `names_hosts`: the comma-separated hosts the names site is served on when `serve_webapp = on`.
|
||
|
||
The service refuses to start with `names_hosts` or `names_export_dir` but no `names_static_dir`.
|
||
|
||
With `serve_webapp = on` (all in one), the listener picks the site by the request's `Host`, in
|
||
lower case and without its port or a trailing dot (an IPv6 literal whole, in brackets), as
|
||
`names_hosts` entries are read: a host in
|
||
`names_hosts` gets the names site, any other host or none the badges site. Every path
|
||
outside `/api/` and `/webhooks/` is a file of the chosen site, `/` and `/index.html` its shell. The
|
||
API answers on every host.
|
||
|
||
### Caddy
|
||
|
||
The Caddyfile is not in this repository. Its two host blocks, with the exported folders at
|
||
`/srv/store-service/web` and `/srv/store-service/names` and the listener at `127.0.0.1:8080`:
|
||
|
||
```
|
||
badges.simplex.chat {
|
||
encode zstd gzip
|
||
handle /api/* {
|
||
reverse_proxy 127.0.0.1:8080
|
||
}
|
||
handle /webhooks/* {
|
||
reverse_proxy 127.0.0.1:8080
|
||
}
|
||
handle {
|
||
root * /srv/store-service/web
|
||
file_server
|
||
}
|
||
@shell path / /index.html /sw.js
|
||
header @shell Cache-Control "no-cache"
|
||
header /assets/* {
|
||
Cache-Control "public, max-age=31536000, immutable"
|
||
match status 2xx
|
||
}
|
||
header Content-Security-Policy "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"
|
||
}
|
||
|
||
simplex.domains {
|
||
encode zstd gzip
|
||
handle /api/* {
|
||
reverse_proxy 127.0.0.1:8080
|
||
}
|
||
handle {
|
||
root * /srv/store-service/names
|
||
file_server
|
||
}
|
||
@shell path / /index.html /sw.js
|
||
header @shell Cache-Control "no-cache"
|
||
header /assets/* {
|
||
Cache-Control "public, max-age=31536000, immutable"
|
||
match status 2xx
|
||
}
|
||
header /manifest.webmanifest Content-Type "application/manifest+json"
|
||
@media path /fonts/* /img/*
|
||
header @media {
|
||
Cache-Control "public, max-age=86400"
|
||
match status 2xx
|
||
}
|
||
header {
|
||
Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-i5ar+ITNdQiucbmin4/AN6DAY5LbAWeq3at+RiFoUoY=' 'sha256-Jhsw0wf9pI4icAxqab9mIjf34YdecDZeLVN+/KVZW+4=' 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' data: https://*.stripe.com; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
||
Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||
X-Content-Type-Options "nosniff"
|
||
Referrer-Policy "strict-origin-when-cross-origin"
|
||
Cross-Origin-Opener-Policy "same-origin-allow-popups"
|
||
}
|
||
}
|
||
```
|
||
|
||
- Both sites call `/api/*` on their own origin, so there is no CORS.
|
||
- `/webhooks/*` is proxied on badges.simplex.chat only. BTCPay and Stripe send every event there,
|
||
names payments included.
|
||
- The shell and `/sw.js` are `no-cache`, since they name the build's hashed files, and Caddy's
|
||
`file_server` sends no `Cache-Control` of its own. `/assets/*` file names carry a content hash.
|
||
The long lifetimes apply to found files only, so a missing asset is not cached for a year.
|
||
- `file_server` sends no type for `.webmanifest`, which the names site's manifest needs.
|
||
- The badges policy is `web/README.md`'s. The names policy is the names webapp's (its README,
|
||
"Hosting"), with `https://*.stripe.com` added to `img-src` beside `data:`, which the link
|
||
profiles' pictures need. Its two hashes are those of the inline theme and font-preload scripts
|
||
in the names `index.html`, and change with them. It allows no inline style.
|
||
- Stripe.js runs on simplex.domains itself, in the document that keeps the owner codes and prepaid
|
||
codes in `localStorage`. The owner accepted this on 2026-10-20, rather than a Checkout redirect
|
||
or a separate card origin. Scripts and frames are allowed only from this origin and Stripe's
|
||
hosts.
|
||
|
||
### Egress rule
|
||
|
||
The link previews check a link's hosts before the chat core fetches it, but the core resolves the
|
||
host again when it connects, so a name that changes its answer in between (DNS rebinding) can
|
||
reach a private address. The guard is a firewall rule: the service's new outbound connections to
|
||
unspecified, private, CGNAT, loopback, link-local, benchmarking, multicast and reserved IPv4
|
||
ranges, and to unique-local, link-local, site-local and multicast IPv6 ranges (with IPv4-mapped,
|
||
IPv4-compatible and NAT64 forms of the IPv4 ones), are rejected, and so are those to any of the
|
||
host's own addresses, public ones included, since a rebinding name can answer with them too and
|
||
reach whatever listens on the host. The exceptions are the
|
||
endpoints the service is configured to use, on their ports: the RPC node (`[chain] rpc_url`),
|
||
BTCPay (`[btcpay] host`), the database and the DNS resolvers in `/etc/resolv.conf`. Replies on
|
||
connections already open, such as Caddy's requests, pass, and so does IPv6 neighbour discovery on
|
||
the link.
|
||
|
||
`egress.sh` writes this as the chain `STORE_SERVICE_EGRESS` in `iptables` and `ip6tables`, and
|
||
rebuilds it on every run. With compose's host networking it matches the service's user in
|
||
`OUTPUT` (`-m owner --uid-owner`). That rule covers every process of that user on the host, so
|
||
the service must run as a dedicated user. `--uid` is required, and the script refuses a uid whose
|
||
host account has a login shell (anything but `nologin` or `false`). Compose's `USER_UID` default
|
||
of 1000 is often the operator's own login, so create a user for the service and run compose with
|
||
its ids, set as `USER_UID` and `USER_GID` in `scripts/store-service/.env`; they are build
|
||
arguments too, so rebuild the image:
|
||
|
||
```
|
||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin simplex-store
|
||
id simplex-store
|
||
mkdir -p web names
|
||
sudo chown -R simplex-store:simplex-store web names
|
||
sudo chown simplex-store store_service.ini && sudo chmod 600 store_service.ini
|
||
docker compose up -d --build --remove-orphans
|
||
```
|
||
|
||
On a Docker bridge network instead, `--bridge <interface>` matches traffic from that network's
|
||
bridge interface in `DOCKER-USER` (traffic forwarded to other hosts) and `INPUT` (the host's own
|
||
addresses), and needs no `--uid`. The script refuses an interface that is not a bridge or that holds a default route (a bridge
|
||
carrying the host's uplink, such as `br0` or `vmbr0`, whose inbound connections it would refuse),
|
||
and stops when `DOCKER-USER` is missing (Docker's nftables backend, `"iptables": false` or
|
||
`"ip6tables": false` in its daemon config, or Docker not started yet): the forwarded traffic would
|
||
then pass unfiltered. Docker before 27 creates the IPv6 `DOCKER-USER` only with both
|
||
`"ip6tables": true` and `"experimental": true`. The interface is `docker0` for the default network; for another, it is the network's
|
||
`com.docker.network.bridge.name` option when set, else `br-` and the first 12 characters of its id:
|
||
|
||
```
|
||
docker network inspect -f '{{.Id}} {{index .Options "com.docker.network.bridge.name"}}' <network>
|
||
```
|
||
|
||
Install it as root from `scripts/store-service/`, here for a service uid of 998. `--db` defaults to
|
||
the environment's `BADGE_DB_CONN`, else to compose's default
|
||
(`postgresql://simplex@127.0.0.1:5432/badge_service`); `sudo` drops the environment, so pass it
|
||
when compose runs with another value:
|
||
|
||
```
|
||
sudo ./egress.sh --dry-run --uid 998
|
||
sudo ./egress.sh --uid 998
|
||
sudo ./egress.sh --check --uid 998
|
||
```
|
||
|
||
- `--dry-run` prints the `iptables-restore` input an install would commit, read against the
|
||
current rules, and changes nothing; read the allowed endpoints in it.
|
||
- `--check` exits 1 and names what differs unless every rule is in place, the chain holds nothing
|
||
else, and each jump is the first rule of its chain.
|
||
- Names are resolved when the rule is installed: run it again after changing the ini, the
|
||
database string or the resolvers, and after a private endpoint's address changes. A name the
|
||
host cannot resolve (a container name) stops the install; pass its address with
|
||
`--allow ADDRESS:PORT`, as for any other endpoint the service needs, such as a SOCKS proxy.
|
||
- Every address of this host is refused unless allowed, public ones included. An SMP or XFTP
|
||
server on the same host, which the service reaches by its public name, needs
|
||
`--allow <name>:<port>` (5223 for SMP, 443 for XFTP by default), as does anything else on this
|
||
host the service connects to.
|
||
- The DNS resolvers allowed are those in `--resolv-conf` (default `/etc/resolv.conf`). With host
|
||
networking that is what the service queries, systemd-resolved's stub included. On a bridge
|
||
network of a host with systemd-resolved, Docker sends the containers' queries to the upstream
|
||
servers in `/run/systemd/resolve/resolv.conf`, so pass that file.
|
||
- On a bridge network every container on it is matched, and the service cannot open connections
|
||
to another container there unless it is listed with `--allow`. Give the service a network of its
|
||
own.
|
||
- The rules do not survive a reboot. Run the script at boot after Docker and DNS are up, from a
|
||
unit ordered after `docker.service` and `network-online.target`: the names in the ini must
|
||
resolve, and bridge mode needs Docker's `DOCKER-USER`. The service is unguarded from its start
|
||
until then. With host networking, saving the rules with `netfilter-persistent` restores them
|
||
before Docker starts.
|
||
|
||
Check it by hand from inside the container, once the service runs. Each check below fails when the
|
||
rule is missing:
|
||
|
||
```
|
||
docker compose exec store-service bash -c ': </dev/tcp/10.255.255.1/80'
|
||
```
|
||
|
||
With host networking:
|
||
|
||
```
|
||
docker compose exec store-service bash -c ': </dev/tcp/127.0.0.1/8080'
|
||
docker compose exec store-service bash -c ': </dev/tcp/127.0.0.1/5432 && echo allowed'
|
||
```
|
||
|
||
On a bridge network, with the network's gateway (the host's address on it) and a port the host
|
||
listens on for every address, such as Caddy's 443:
|
||
|
||
```
|
||
docker network inspect -f '{{(index .IPAM.Config 0).Gateway}}' <network>
|
||
docker compose exec store-service bash -c ': </dev/tcp/<gateway>/443'
|
||
```
|
||
|
||
- A refused connection answers `Connection refused` at once, sent by the rule. Without the rule
|
||
the connection to 10.255.255.1 waits for a timeout instead, and the one to 127.0.0.1:8080 (the
|
||
service's own listener) or to the gateway connects.
|
||
- `allowed` is printed for the database. With the RPC node on this host, the same command with its
|
||
port (`8545` for a local reth) prints it too.
|
||
|
||
`sudo ./egress.sh --remove --uid 998` (or `--bridge <interface>`) deletes the jumps an install
|
||
adds for that user or network, then the chain, in `iptables` and `ip6tables`. Running it again
|
||
changes nothing, and `--check` then reports the chain absent.
|
||
|
||
### The gas key
|
||
|
||
`[chain] service_key` is the registrar key: an Ethereum account (EOA) that sends every names
|
||
transaction, `commit` to the controller, `registerWithCredit` to the sponsor, and the owners' record
|
||
edits (`setTextWithSig`, `clearRecordsWithSig`) to the resolver. It pays only their gas, in ETH. The
|
||
names' rent is spent from its allowance on the sponsor, in USD, which the guardian sets. It holds no
|
||
names.
|
||
|
||
The commands run from `scripts/store-service/` on the service host, with Foundry's `cast`
|
||
([getfoundry.sh](https://getfoundry.sh), checked with 1.5.1) and `jq`. They read the ini through this
|
||
shell function, which takes a key's value without surrounding spaces, tabs or a CR:
|
||
|
||
```
|
||
ini() { sudo sed -n "s/^$1[[:space:]]*=[[:space:]]*\([^[:space:]]*\).*/\1/p" "${2:-store_service.ini}"; }
|
||
```
|
||
|
||
Keys and the RPC URL are read through `ini`, so they stay out of the shell history. A key is in
|
||
cast's arguments, and the RPC URL in curl's, visible to `ps` while it runs.
|
||
|
||
The ini is never edited in place. `sudoedit` refuses a file in a directory the invoking user can
|
||
write (sudo(8), sudoers(5) `sudoedit_checkdir`), as `scripts/store-service/` of the checkout is.
|
||
`sudo <editor>` would leave swap or backup files holding the key beside it, which git does not
|
||
ignore. So each change is made in a copy in a root-only directory and copied over the ini, which
|
||
keeps its owner, mode and inode, so compose's read-only mount sees it. `next/store_service.ini` and
|
||
the other copies below are ignored by git, as every `store_service.ini` is, and are outside the
|
||
image.
|
||
|
||
#### Create the key
|
||
|
||
Fill `[names]` and `[chain]` in a copy, with a `service_key = 0x...` line under `[chain]`. Then
|
||
generate the key into that line, so it is never printed, and put the copy in place:
|
||
|
||
```
|
||
sudo install -d -m 700 next && sudo cp -p store_service.ini next/store_service.ini
|
||
sudoedit next/store_service.ini
|
||
cast wallet new --json | jq -r '"s/^service_key[[:space:]]*=.*/service_key = " + .[0].private_key + "/"' | sudo sed -i -f - next/store_service.ini
|
||
cast wallet address "$(ini service_key next/store_service.ini)"
|
||
sudo cp next/store_service.ini store_service.ini && sudo rm -r next
|
||
```
|
||
|
||
The address check prints the key's address, as in `0xB2590B3a4fa5bEdda803F5f1F605803438C3F71E`, and
|
||
fails when the line was not replaced. The key is `0x` and 64 hex digits.
|
||
|
||
The ini holds the only copy of the key. It is gitignored, every `*.ini` is kept out of the image,
|
||
and compose mounts it read-only. Keep it owned by the service user with mode 600 (see
|
||
[Egress rule](#egress-rule)). The service never logs the key; keep it out of logs and tickets too.
|
||
If a key was ever shown on a terminal, clear the screen and its scrollback (`clear && printf
|
||
'\e[3J'`, and `tmux clear-history` in tmux), and never show one in a recorded session.
|
||
|
||
#### Set up the shell
|
||
|
||
```
|
||
export ETH_RPC_URL="$(ini rpc_url)"
|
||
CONTROLLER=$(ini controller)
|
||
SPONSOR=$(ini sponsor)
|
||
REGISTRAR=$(cast wallet address "$(ini service_key)")
|
||
```
|
||
|
||
cast reads the node from `ETH_RPC_URL`, so no command below names it. The address check reads the
|
||
ini on disk, not the running service, which logs no address. To confirm the service runs with the
|
||
key, check that `cast nonce $REGISTRAR --block pending` rises after its next transaction.
|
||
|
||
#### Grant the allowance
|
||
|
||
Only the guardian, the controller's beneficiary, can set the allowance:
|
||
|
||
```
|
||
cast call $CONTROLLER 'beneficiary()(address)'
|
||
```
|
||
|
||
The guardian sends this call to `$SPONSOR`, with no value, from whatever wallet holds its key. Its
|
||
calldata, here for $1,000:
|
||
|
||
```
|
||
cast calldata 'setRegistrarAllowance(address,uint256)' $REGISTRAR $(cast parse-units 1000)
|
||
```
|
||
|
||
```
|
||
0x7f8ac8b8000000000000000000000000b2590b3a4fa5bedda803f5f1f605803438c3f71e00000000000000000000000000000000000000000000003635c9adc5dea00000
|
||
```
|
||
|
||
- From a key, with cast (`--interactive` prompts for the key; `--ledger` signs on a Ledger instead):
|
||
|
||
```
|
||
cast send $SPONSOR 'setRegistrarAllowance(address,uint256)' $REGISTRAR $(cast parse-units 1000) --interactive
|
||
```
|
||
|
||
- From a multisig such as Safe: submit that calldata to the sponsor address as a transaction with
|
||
value 0 in the Safe app.
|
||
|
||
Then anyone can check the result:
|
||
|
||
```
|
||
cast call $SPONSOR 'registrarAllowance(address)(uint256)' $REGISTRAR
|
||
```
|
||
|
||
```
|
||
1000000000000000000000 [1e21]
|
||
```
|
||
|
||
- The amount is in attoUSD: dollars × 10^18, or cents × 10^16. `cast parse-units 1000` gives $1,000.
|
||
- It replaces the allowance and does not add to it. To top it up, read what is left and send the
|
||
new total.
|
||
- Zero is the kill switch: `setRegistrarAllowance($REGISTRAR, 0)` stops every register from that
|
||
key in one transaction. A sender that is not the guardian is refused with `NotBeneficiary`.
|
||
- Each register spends the name's list price (`rentPriceUSD`, base plus premium) for its whole
|
||
duration, the free days before `[names] term_start` included. For example, 2 years of a name of 8
|
||
letters or more:
|
||
|
||
```
|
||
cast call $CONTROLLER 'rentPriceUSD(string,uint256)((uint256,uint256))' examplename 63072000
|
||
```
|
||
|
||
```
|
||
(100000000000000000000 [1e20], 0)
|
||
```
|
||
|
||
#### Fund the key
|
||
|
||
Send ETH on the chain of `[chain] chain_id` from any wallet, and top it up the same way. With cast:
|
||
|
||
```
|
||
cast send $REGISTRAR --value 1ether --interactive
|
||
```
|
||
|
||
The gas below was measured on a local anvil deployment of the contracts (the names webapp's
|
||
`dev/deploy.ts`), with a 63-letter label and links of 4096 bytes, the most the service accepts:
|
||
|
||
| Transaction | Gas limit | Gas used | Limit × 100 gwei |
|
||
|---|---|---|---|
|
||
| `commit` | 58,983 | 53,521 | 0.0059 ETH |
|
||
| `registerWithCredit`, no links | 568,116 | 504,408 | 0.0568 ETH |
|
||
| `registerWithCredit`, one link | 3,673,412 | 3,489,551 | 0.3673 ETH |
|
||
| `registerWithCredit`, two links | 6,791,417 | 6,467,815 | 0.6791 ETH |
|
||
| `setTextWithSig`, a new link | 3,026,449 | 3,026,449 | 0.3026 ETH |
|
||
| `setTextWithSig`, a link replacing one | 462,289 | 462,289 | 0.0462 ETH |
|
||
| `setTextWithSig`, a link removed | 698,644 | 558,916 | 0.0699 ETH |
|
||
| `clearRecordsWithSig` | 80,312 | 80,312 | 0.0080 ETH |
|
||
|
||
- The service sets each transaction's gas limit to its estimate and its max fee to at most
|
||
`max_fee_per_gas` (default 100 gwei). The node accepts a transaction only while the balance covers
|
||
its limit × max fee, so the last column is what each one needs held. What it is charged is gas
|
||
used × the block's base fee plus tip, usually far less.
|
||
- An order is one commit and one register: 0.6850 ETH at the cap with two links, 0.0627 ETH with
|
||
none. The expected orders × 0.6850 ETH, plus 0.3026 ETH per expected link edit, pays for all of
|
||
them even at the cap. The figures scale with `max_fee_per_gas`.
|
||
- `gas_limit_cap` (default 8,000,000) must stay above 6,791,417: a transaction whose estimate is
|
||
above it is not sent.
|
||
|
||
#### Check the balance and transactions in flight
|
||
|
||
```
|
||
cast balance $REGISTRAR --ether
|
||
cast nonce $REGISTRAR --block latest
|
||
cast nonce $REGISTRAR --block pending
|
||
```
|
||
|
||
The balance prints as `1.000000000000000000`. The `latest` nonce counts the key's mined
|
||
transactions, and the `pending` one adds those waiting in the node's pool; the difference is what is
|
||
in flight. The same reads through JSON-RPC answer in hex wei and hex counts
|
||
(`{"jsonrpc":"2.0","id":1,"result":"0xde0b6b3a7640000"}`, which `cast to-unit 0xde0b6b3a7640000 ether`
|
||
prints as `1`):
|
||
|
||
```
|
||
curl -s -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["'"$REGISTRAR"'","latest"]}' "$ETH_RPC_URL"
|
||
curl -s -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionCount","params":["'"$REGISTRAR"'","latest"]}' "$ETH_RPC_URL"
|
||
curl -s -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionCount","params":["'"$REGISTRAR"'","pending"]}' "$ETH_RPC_URL"
|
||
```
|
||
|
||
A transaction the node refused is in neither count. The service's own list is the `pending` rows of
|
||
its transactions table, here with compose's default database and schema:
|
||
|
||
```
|
||
psql "postgresql://simplex@127.0.0.1:5432/badge_service" -c "SELECT registrar_nonce, kind, sent_at FROM simplex_v1_chat_schema.sx_badge_service_name_transactions WHERE status = 'pending' ORDER BY registrar_nonce"
|
||
```
|
||
|
||
#### When funds or the allowance run out
|
||
|
||
`docker compose logs store-service` shows:
|
||
|
||
- `[ERROR …] names sender: nonce <n>: not sent: …`, with the node's `insufficient funds` message,
|
||
when the key cannot pay. The transaction is saved and sent again every few seconds, so funding the
|
||
key is enough and needs no restart. The line is logged again only when the node's message changes.
|
||
- `[ERROR …] names worker: registration <id> failed: the registrar's allowance on the sponsor is
|
||
used up; the guardian raises it with setRegistrarAllowance` when the sponsor refuses a register
|
||
with `InsufficientAllowance`. The order is failed; a code order keeps its code for Try again.
|
||
- `[WARN …] names sender: the latest block needs <wei> wei per gas, above max_fee_per_gas <cap>;
|
||
the transaction waits` when fees are above the cap. Nothing is signed until they fall.
|
||
- `[WARN …] names sender: nonce <n>: not replaced: a replacement would pay over max_fee_per_gas
|
||
<cap>` when a transaction stuck in the pool cannot be bumped within the cap.
|
||
|
||
#### Rotate the key
|
||
|
||
The service resumes its saved transactions at start and gives new ones nonces after theirs. Those
|
||
are signed by the key that sent them, so started on another key it would give that key's
|
||
transactions nonces it has not reached, and they would never be mined. Rotate only with no `pending`
|
||
row, at a quiet time: every stop takes the store offline, and each order sends its register about a
|
||
minute after its commit.
|
||
|
||
1. Keep a copy of the old ini, create the new key in another, fund it, and have the guardian grant
|
||
it an allowance, as above for `$NEW`:
|
||
|
||
```
|
||
sudo install -d -m 700 prev next
|
||
sudo cp -p store_service.ini prev/store_service.ini && sudo cp -p store_service.ini next/store_service.ini
|
||
cast wallet new --json | jq -r '"s/^service_key[[:space:]]*=.*/service_key = " + .[0].private_key + "/"' | sudo sed -i -f - next/store_service.ini
|
||
OLD=$(cast wallet address "$(ini service_key prev/store_service.ini)")
|
||
NEW=$(cast wallet address "$(ini service_key next/store_service.ini)")
|
||
```
|
||
|
||
2. Stop the service and count the `pending` rows:
|
||
|
||
```
|
||
docker compose stop store-service
|
||
psql "postgresql://simplex@127.0.0.1:5432/badge_service" -tAc "SELECT count(*) FROM simplex_v1_chat_schema.sx_badge_service_name_transactions WHERE status = 'pending'"
|
||
```
|
||
|
||
On `0`, go to step 3. Otherwise `docker compose start store-service` on the old key, let them
|
||
settle (`confirmations` blocks after they are mined), wait for a pause in the orders, and repeat
|
||
this step. For a drain that new orders cannot hold up, start the old key with
|
||
`max_fee_per_gas = 1` under `[chain]` instead:
|
||
|
||
```
|
||
sudo install -d -m 700 drain && sudo cp -p store_service.ini drain/store_service.ini
|
||
sudoedit drain/store_service.ini
|
||
sudo cp drain/store_service.ini store_service.ini
|
||
docker compose start store-service
|
||
```
|
||
|
||
With a cap of 1 wei the service signs no new transaction. Each new one waits and logs the fee
|
||
warning, and an owner's link edit is answered as unavailable. The saved transactions are still
|
||
followed and settle. They are not bumped above the cap, so one that stays pending for long needs
|
||
the old ini back (`sudo cp prev/store_service.ini store_service.ini` and a restart) to be
|
||
replaced. Step 3 puts the full cap back, since `next/` was copied before.
|
||
3. Put the new ini in place, start, and check that the ini on disk now holds the new key:
|
||
|
||
```
|
||
sudo cp next/store_service.ini store_service.ini
|
||
docker compose start store-service
|
||
cast wallet address "$(ini service_key)"
|
||
```
|
||
|
||
It prints `$NEW`. After the next order's commit, `cast nonce $NEW --block pending` is above 0,
|
||
and `cast nonce $OLD --block pending` stays where it was.
|
||
4. The guardian zeroes the old key's allowance, sending this calldata as in
|
||
[Grant the allowance](#grant-the-allowance), then anyone checks it reads 0:
|
||
|
||
```
|
||
cast calldata 'setRegistrarAllowance(address,uint256)' $OLD 0
|
||
cast call $SPONSOR 'registrarAllowance(address)(uint256)' $OLD
|
||
```
|
||
|
||
5. Send the old key's ETH back to a plain account (a transfer to a contract such as a Safe needs
|
||
more than the 21,000 gas used here), then delete the copies. The first send leaves 0.01 ETH for
|
||
the fees. The second sweeps the rest, as a legacy transaction that pays exactly its gas price,
|
||
twice the node's current one, so the balance ends at 0:
|
||
|
||
```
|
||
cast balance $OLD --ether
|
||
cast send <account> --value <balance - 0.01>ether --private-key "$(ini service_key prev/store_service.ini)"
|
||
P=$(( 2 * $(cast gas-price) ))
|
||
cast send <account> --legacy --gas-limit 21000 --gas-price $P --value $(( $(cast balance $OLD) - 21000 * P )) --private-key "$(ini service_key prev/store_service.ini)"
|
||
cast balance $OLD
|
||
sudo rm -rf prev next drain
|
||
```
|
||
|
||
The last balance prints `0`. A negative value, from fees above about 150 gwei, makes cast refuse
|
||
the sweep; run it again when they fall.
|
||
|
||
## Issuing codes
|
||
|
||
Operators issue codes two ways: from the service's own command line in `--run-cli` mode, and from the
|
||
managed group in service mode. Both are commands to a running process, so no second process
|
||
touches the service's database.
|
||
|
||
### From the command line
|
||
|
||
The command is sent to the running service in `--run-cli` mode, not a way to start it:
|
||
|
||
```
|
||
//issue <badge_type> [months] [paid|unpaid|free]
|
||
//issue supporter 12
|
||
//issue name <length> [years <Y>] [paid|unpaid|free]
|
||
//issue name 7 years 5
|
||
```
|
||
|
||
`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 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.
|
||
|
||
`//issue name` makes a prepaid name code (`SN<length>-…`): it pays for one name of at least
|
||
`<length>` letters, 6 to 63, for `years` years, 2 to 10 (default 2). The status is as above: an
|
||
order refuses an `unpaid` name code with `payment_pending`.
|
||
|
||
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, badge and name codes alike:
|
||
|
||
```
|
||
//revoke <code>
|
||
```
|
||
|
||
A revoked code answers redemption with `code_invalid`, as if it had never existed, so its holder
|
||
learns nothing from trying. A client that redeemed it before the revoke still gets its own badge
|
||
back when it asks again. Revoking it again answers "already revoked" and fixes its group
|
||
message if the first revoke didn't. A code with no uses left can't be revoked, because its badges
|
||
were already given out, and the command answers with an error. A multi-use code with uses left can
|
||
be revoked, which stops the uses that remain.
|
||
|
||
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. `//refund`, the other command of this
|
||
console, is in [Refunding a name balance](#refunding-a-name-balance).
|
||
|
||
### From the group
|
||
|
||
With `[group]` in `store_service.ini`, the service manages one group and serves three commands in
|
||
it: `/issue <type> [months <M>] [uses <N>]` and `/bulk <type> [months <M>] count <B>` for moderators
|
||
and above, `/revoke <code>` for admins and owners. `months` is 1 to 255, `uses` 1 to 1000 and
|
||
`count` 1 to 100; a value outside these gets the usage reply. The name forms are
|
||
`/issue name <length> [years <Y>]` and `/bulk name <length> [years <Y>] count <B>`, with the
|
||
bounds of `//issue name`; a name code issued in the group is single-use and `free`. `/revoke`
|
||
takes badge codes only: withdraw a name code with `//revoke` in `--run-cli` mode. The group has no
|
||
`/refund`. A member's role
|
||
is checked as the service last saw it, so a command sent by a moderator just demoted or removed can still run if it
|
||
reaches the service first; revoke any code the service posts for them after the change. `uses`
|
||
above 1 makes a multi-use code, tracked by a group message showing its remaining uses and the time
|
||
of the last one; when every use is redeemed, the same message says so. Every reply carrying a code is read by every member,
|
||
since the group has no private lane, so a code issued there is only as private as its least trusted
|
||
member.
|
||
Those replies are also kept as plain text in the service's chat database, so a copy of the database
|
||
holds every code issued in the group. Keep the group's visible history off: with it on, each new
|
||
member receives recent messages, and the codes in them, when they join. A multi-use code's message
|
||
carries the code, and every redemption edits it or, after a day, posts it again; either way every
|
||
current member receives it, so a member who joined after the code was issued gets the code while it
|
||
still has uses left. A message replaced by a new post stays in the group with its old count. Keep
|
||
disappearing messages off in the group and set no message TTL for the service's chats: a code's
|
||
message that expires is treated as deleted and never posted again, so its counter stops.
|
||
Every member can see when each use of a multi-use code was redeemed: the message shows the time of
|
||
the last one, and its edit times show the rest.
|
||
`/revoke <code>` names the code in an ordinary group message, so every member holds it before the
|
||
service reads the command, and the code stays redeemable until the service acts on it — for the
|
||
whole of any downtime. Revoke a code that is not already public in the group, a refunded one above
|
||
all, with `//revoke` in `--run-cli` mode. A `/revoke <code>` with nothing after the code, from a
|
||
member below admin, is answered that the code was not revoked and is now visible to the group. A
|
||
group command the service received but had not run when it stopped, or received while it ran in
|
||
`--run-cli` mode, is dropped with no reply, so resend it, or use `//revoke`.
|
||
|
||
The first member to join through the link is promoted to owner, so the operator joins before sharing
|
||
it. Keep the service an owner too: below owner it cannot update the group's command menu, and below
|
||
author it cannot post codes or replies. A failed promotion is logged at once, and an owner who left
|
||
is logged at the next start or join. Then make the member you choose owner with the `/mr` command
|
||
that the log line names, in `--run-cli` mode; the service never promotes anyone once the first
|
||
promotion was attempted.
|
||
|
||
The join link logged when the group is created stays valid: anyone who has it can join later, as a
|
||
member, and read every code posted or edited from then on. That includes a removed member, who can
|
||
rejoin through it, so removing a member does not stop them seeing new codes. Keep the log that holds
|
||
it private. The link is also stored in the `group_link` column of `sx_badge_service_group`, where it
|
||
can be read again.
|
||
|
||
If an owner deletes the group, or removes the service from it, the service logs an error on start
|
||
and stops serving the group. To create a new group, stop the service, delete the row, and start it
|
||
again: with SQLite, run `DELETE FROM sx_badge_service_group;` on the `<prefix>_chat.db` file
|
||
(`~/.simplex/simplex_badge_service_chat.db` by default), opened with `sqlcipher` and the database key
|
||
if one is set; with PostgreSQL, run
|
||
`DELETE FROM <schema-prefix>_chat_schema.sx_badge_service_group;` (`simplex_v1_chat_schema` by default).
|
||
Multi-use codes issued in the old group stay redeemable, but their messages there are no longer
|
||
updated, so revoke with `//revoke` any that should not stay live.
|
||
|
||
The group is identified by the single `sx_badge_service_group` row. Rolling back past the
|
||
`20260918_badge_group_ops` migration drops that table, so a later re-upgrade creates a second group
|
||
and orphans the first one with its members and roles; multi-use codes come back single-use with
|
||
their claims re-derived, and outstanding trackers come back unanchored. Redeemed credentials are
|
||
preserved and no code becomes redeemable again, though while the old version runs, only the holder
|
||
whose credential ends last gets it back on a retry, and any other holder of a multi-use code gets
|
||
`code_used`; every holder of a revoked code gets `code_invalid`. Rolling back means re-creating and
|
||
re-sharing the group; delete the orphaned one with `/d #'<old local name>'` in `--run-cli` mode, as its
|
||
join link still works and its messages hold every code posted there.
|
||
|
||
The configured `display_name` and `description` apply only to the group the service creates. Editing
|
||
them later is logged as not applied and changes nothing.
|
||
|
||
## Refunding a name balance
|
||
|
||
A paid name order keeps its money as a balance when it ends `taken` (another buyer registered the
|
||
name first), when it moves to a cheaper name (the difference), and when a payment reaches its invoice
|
||
after the invoice closed. The buyer spends a balance on another name from Your names, or asks for it
|
||
back: the page tells them to write to chat@simplex.chat with the order's reference, which is its
|
||
invoice id, or its registration id for an order without an invoice. Nothing is refunded
|
||
automatically, and the service never learns where to send money. Support records each refund in
|
||
`--run-cli` mode with the service stopped, like the other `//` commands:
|
||
|
||
```
|
||
//refund <reference>
|
||
//refund <reference> <cents>
|
||
```
|
||
|
||
1. Run `//refund <reference>` with the reference the buyer sent. It records nothing and answers the
|
||
balance, what paid for it and the command that refunds it:
|
||
|
||
```
|
||
Order <id>: balance $100.00 (credited $100.00, refunded $0.00). Paid by: order <id>: $100.00 by btc on invoice <invoice id>. To record its refund: //refund <reference> 10000
|
||
```
|
||
|
||
`Paid by` starts at this order and follows every balance the money came through back to the
|
||
first order: each order with what its invoice paid and by which method (`card`, `btc` or
|
||
`xmr`), the part paid from the balance of the next order listed, and, marked `late`, money
|
||
credited after its invoice closed.
|
||
2. Run the command the answer ends with. The cents must equal the whole balance: a refund zeroes it,
|
||
and part of a balance is never refunded. Other cents record nothing, and the answer gives the
|
||
balance.
|
||
3. The answer, `Recorded a refund of $100.00 for order <id>. Refund it now, by: …`, repeats the
|
||
chain. Pay the money back now, by the methods it lists: a card payment as a refund in the Stripe
|
||
Dashboard, crypto to an address the buyer gives, at that day's rate, since the balance is in US
|
||
cents. The refund is recorded first so that the buyer cannot spend the money while it is paid
|
||
back. The service logs each recorded refund at info with the order's registration id and the
|
||
cents.
|
||
|
||
A balance that an open order holds is never refunded: that order is buying another name with it and
|
||
its invoice is waiting for the difference. The command names the order and its invoice's expiry
|
||
instead:
|
||
|
||
- `its invoice open until <time>`: if the buyer pays it, the balance is spent; if not, it is released
|
||
once the invoice expires and the poller, in service mode, closes the order. Run the command again
|
||
then.
|
||
- `its invoice expired at <time>; start the service so the poller closes that order, 10 minutes
|
||
after the expiry at the earliest, then stop it and run //refund again`: the poller runs only in
|
||
service mode. Its first pass from 10 minutes after the expiry closes the order and releases the
|
||
hold; a card order closes only once Stripe accepts the cancel.
|
||
- `whose invoice expired at <time> holding money`: a payment reached the invoice; the hold stays
|
||
until that payment settles, which spends the balance, or the provider closes the invoice, which
|
||
releases it.
|
||
|
||
The command also answers when the reference matches no order, when the order has no balance, and
|
||
when the cents are not the balance. A refunded balance shows `amount` 0 and its `refunded` total in
|
||
Your names; a later late payment credits it again, and a second refund adds to `refunded`.
|