fix(wallet): resume restores and preserve invoice payment policy

This commit is contained in:
2026-09-27 07:23:09 -04:00
parent 9536dd099c
commit d90a57bb11
18 changed files with 573 additions and 316 deletions
+3
View File
@@ -80,6 +80,9 @@ PAYMENT_AMOUNT=0.01
PAYMENT_CURRENCY=XMR
# Confirmations-to-finalize (mapped to the processor's speed policy), and how long
# an unpaid invoice stays payable before it expires.
# BTCPay supports 1, 2, or 6 confirmations; 3-5 rounds up to 6. Values above
# 6 are refused because the primary processor cannot enforce them. Invoice
# requests pin paymentTolerance=0, overriding any store underpayment allowance.
PAYMENT_CONFIRMATIONS=1
PAYMENT_EXPIRES_MINUTES=60
+3 -4
View File
@@ -92,10 +92,9 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# landing is here, not only in the DB-less job, because the public stats
# block reads the database: without a DB those tests self-skip and the
# "no identifier on the public surface" assertion never actually runs.
- run: cargo test --locked --test auth_flow --test db_queries --test landing
# Run every integration target with Postgres. A hand-picked list silently
# skips payment, registration, wallet and admin tests in the DB-less job.
- run: cargo test --locked --tests
supply-chain:
name: cargo-deny
+22 -5
View File
@@ -1,5 +1,7 @@
# Privacy Policy
> Status: stable · Updated 2026-09-27 · Applies to: backend data handling
The canonical Smirk privacy policy lives at **https://smirk.cash/privacy** and
describes the data practices of the instance operated by Such Software LLC
(api.smirk.cash).
@@ -13,12 +15,16 @@ speak for you. What your instance receives from wallets that connect to it:
- **Monero/Wownero view keys** (to scan for users' incoming funds; a view key
cannot spend) and their scan/registration state.
- **Public addresses** for BTC/LTC balance lookups and **signed transactions** to
broadcast. Recipient address and amount are not sent on broadcast.
- A one-way **seed fingerprint** (SHA-256) as a non-reversible wallet identifier.
broadcast. A signed Bitcoin or Litecoin transaction contains its output
addresses and amounts; sending it through the backend exposes those public
transaction fields to the backend.
- A client-derived **seed fingerprint** used to locate a returning wallet. The
backend stores an HMAC of it under an instance-specific pepper, rather than
the client value. A fingerprint is not a seed or proof of ownership.
- If enabled: the **username to npub** mapping published in your NIP-05 directory,
and, for the relay, encrypted message envelopes (ciphertext only).
- IP addresses **never in raw form**. The per-IP rate limiter keeps its counters
in memory, and the only IP-derived value written to disk is a salted one-way
- Client IP addresses for rate limiting. The application keeps its rate-limit
counters in memory; its persisted restore-attempt identifier is a salted one-way
hash on `restore_attempts`, which the seed-restore abuse limiter reads back.
Login events record no IP at all, and the raw-IP columns that older schemas
carried on `sessions` and `audit_logs` were dropped in migration
@@ -26,7 +32,11 @@ speak for you. What your instance receives from wallets that connect to it:
than your users: the tamper-evident operator audit chain (`admin_audit_logs`)
records the IP of privileged admin actions.
Private keys and seed phrases are never transmitted to the backend.
Private spend keys and seed phrases are never transmitted to the backend.
Monero and Wownero private incoming view keys are transmitted for scanning, and
the configured light wallet servers retain them. They reveal incoming payments
but cannot spend funds. Reverse proxies and chain services have their own logging
and retention settings, which operators must disclose separately.
## Retention
@@ -49,3 +59,10 @@ how you handle this data, your retention, and your jurisdiction. The knobs that
affect retention and data exposure (login-event and audit retention, erasure,
relay retention, landing-page exposure) are documented in
[docs/operations/CONSOLE.md](docs/operations/CONSOLE.md) and the root `.env.example`.
## Operator checklist
- [ ] Document the light wallet servers that receive and retain view credentials.
- [ ] Match proxy and chain-service logging to the published privacy policy.
- [ ] Configure retention and test account export and erasure.
- [ ] Keep spend keys and recovery phrases on clients.
+27 -13
View File
@@ -1,5 +1,7 @@
# smirk-backend-core
> Status: stable · Updated 2026-09-27 · Applies to: backend v0.3.0 source
Open, self-hostable backend for the [Smirk](https://smirk.cash) non-custodial
multi-chain wallet. Rust + Axum + PostgreSQL.
@@ -8,17 +10,17 @@ estimation, broadcast), Nostr-native identity, a fiat price feed, an async
Grin slatepack relay, and a public social-tips subsystem, all **without ever
holding a spend key or seed**. Run your own; the wallet is backend-agnostic.
> **Status:** v0.3.0 — feature-complete and security-reviewed, but young. The
> schema/API may still evolve. Run your own chain backends where you can, review
> the code, and treat early deployments accordingly.
The source targets v0.3.0. Release readiness depends on the checks for the exact
candidate and its deployment evidence; the version number is not an audit or a
deployment record.
## Non-custodial by construction
The server stores **public addresses, public keys, and (for Monero/Wownero)
incoming view keys only** — never a spend key, never a seed. View credentials are
**forwarded per request** to the chain backends that scan with them and are not
persisted here. The wallet signs and broadcasts locally; the backend reads chains
and relays bytes.
The backend stores public addresses and identity keys. For Monero and Wownero,
it receives private incoming view keys and forwards them to the light wallet
server, which stores them to scan incoming payments. The backend does not persist
those view keys in its own database. Spend keys and seeds remain on the client.
The wallet signs locally, and the backend relays signed transactions to the chain.
## Chains
@@ -35,7 +37,8 @@ is reported `enabled: false` by `/capabilities` rather than failing at call time
The HTTP contract is generated from the handlers and committed to
[`openapi.json`](openapi.json) — the single source of truth for the API and the
wallet's generated client. A CI gate fails the build on any drift.
wallet's generated client. The declared workflow checks regeneration for drift;
release evidence must confirm that the workflow actually ran for the candidate.
Public surface (`/api/v1`):
@@ -124,8 +127,9 @@ then authenticates with the matching key over NIP-98. Full deployment guide:
are cryptographically distinct from user tokens.
- **Bounded, hardened upstream I/O** — TLS with hostname verification, streaming
size caps, and per-request timeouts on every external call.
- **Adversarially reviewed** — every security-critical subsystem was built clean
(never ported) and put through a multi-agent adversarial review before landing.
- **Verification**: unit tests, database integration tests, schema drift checks,
and dependency checks cover different failure modes. Passing them is not a
claim of an independent security audit.
Report vulnerabilities privately — see [SECURITY.md](SECURITY.md). Please do not
open public issues for security reports.
@@ -134,13 +138,23 @@ open public issues for security reports.
```sh
cargo test # unit + doc tests (no database needed)
TEST_DATABASE_URL=postgres://… cargo test --tests # + L1 integration against Postgres
cargo test --tests # L1 integration, with TEST_DATABASE_URL supplied privately
```
Integration tests self-skip when `TEST_DATABASE_URL` is unset. They use only
Database integration tests self-skip when `TEST_DATABASE_URL` is unset. CI's
Postgres job supplies it and runs every integration target. A local pass without
that variable does not verify database behavior. The tests use only
deterministic, non-sensitive test secrets and ephemeral identities — never a
funded or otherwise sensitive wallet seed.
## License
[MIT](LICENSE) © Such Software LLC — run it, embed it, modify it. Built for interop.
## Verification checklist
- [ ] Build and test the exact release candidate with the locked dependencies.
- [ ] Run all database integration tests against a disposable PostgreSQL database.
- [ ] Regenerate OpenAPI and the console bundle from their sources and check drift.
- [ ] Verify each enabled chain against its configured source.
- [ ] Use reviewed Fleet plan/apply procedures for company production deployments.
+69 -192
View File
@@ -1,212 +1,89 @@
# Spec — Premium relay access (recurring)
# Premium relay access
Status: **implemented** — a design overview of the as-built feature (shipped v0.3.0).
> Status: stable · Updated 2026-09-27 · Applies to: backend v0.3.0 source
## 1. Summary
Premium buys a prepaid period of general posting on this instance's Nostr relay.
It does not grant custody, change wallet balances, or charge a user automatically.
The feature defaults off. Plans and payment methods come from the configured
instance, so examples here are not production pricing or deployment evidence.
A **premium tier**: registration and wallet use stay **free** (PoW-gated); an
optional **recurring fee** (e.g. $5 / quarter) grants **posting access to the
operator's Nostr relay**. This reframes payment from *pay-to-register* (a
one-time gate on wallet creation) to a *subscription* (a recurring gate on relay
writes). Everything else — register, unlock, balances, tip stats, receiving DMs,
posting to public relays — remains free.
## Access policy
This is the flagship **cookbook recipe** (`examples/paid-relay/`): a complete,
working demonstration of operator monetization, built entirely on seams that
already exist (PaymentProvider + the relay admission engine).
`RELAY_WRITE_POLICY=premium-post` admits registered users' wallet events for free:
NIP-59 gift wraps (kind 1059, including encrypted messages and tip payloads) and
NIP-17 relay lists (kind 10050). Active premium permits other Nostr event kinds.
The operator's explicit write allowlist also permits general posting; a note on
the feed therefore does not prove that its author paid for premium.
## 2. Goals / non-goals
External authors may deliver gift wraps to registered recipients, subject to the
configured proof of work. Reads remain open. Other public relays apply their own
policies. Wallet registration can have independently configured gates; premium
does not determine those gates.
**Goals**
- Recurring revenue with real utility (relay posting), not a cosmetic badge.
- Compose from existing seams — no new architecture.
- Operator-configurable + advertised via `/capabilities`; self-hosting bypasses it.
- Rigorous: migration, OpenAPI, capabilities golden, unit + L2 + E2E tests.
The authority is `src/infra/relay/policy.rs`, called by the relay admission service.
The wallet uses `/premium/status` to display the server's `can_post_general`
decision. A client display is never sufficient authorization to publish.
**Non-goals (v1)**
- **Read-gating.** Reads stay open (or DM-reads via NIP-42 later). Paid relays
conventionally gate *writes*; gating reads needs NIP-42 AUTH + a premium check
and is a possible v2.
- **Auto-charge subscriptions.** Crypto has no card-on-file; premium is a
**prepaid period** — the user re-pays each term or posting lapses. Honest + simple.
- **Fiat.** Uses the existing crypto PaymentProvider; fiat on-ramp is separate.
## Purchase and renewal
## 3. Model
1. The authenticated client selects a plan and optional payment rail from
`/capabilities`, then posts `{plan, rail}` to `/premium/invoice`.
2. The backend mints an invoice for the server-configured price and period. It
records the user, processor, amount, currency, period and payment-window expiry.
3. The user pays at the returned checkout URL. The client calls
`/premium/activate` with the invoice ID after payment.
4. The backend queries the processor that minted that invoice. A settled payment
bound to this user can be consumed once. Consumption and extending
`premium_until` occur in one database transaction.
The free tier includes everything that makes the **wallet** work — including
publishing **wallet-functional events** to the operator's relay (encrypted DMs that
ride with tips, and future P2P atomic-swap coordination). Premium adds using that
relay as a **general-purpose Nostr relay** (notes, reactions, anything).
Early renewal extends from the later of the current expiry and now. A consumed
invoice cannot extend the period twice. Disabling a payment rail leaves existing
invoice bindings intact; activation refuses until that rail is available again.
| Capability | Free | Premium |
|---|---|---|
| Register / unlock / use wallet (all chains) | ✅ | ✅ |
| Login, view tip/balance stats | ✅ | ✅ |
| Receive DMs (gift-wrap delivery to you) | ✅ | ✅ |
| Publish **wallet events** to the operator's relay (encrypted DMs, tips, swap coordination) | ✅ | ✅ |
| Publish to **public** relays (damus, nos.lol…) | ✅ | ✅ |
| Publish **general Nostr** to the operator's relay (kind-1 notes, reactions, …) | ❌ | ✅ while `premium_until > now` |
The backend limits unconsumed invoices whose payment windows remain open. Expired
windows release capacity, but their rows remain so a paid invoice can still be
redeemed after confirmation. Existing rows created before expiry was recorded
receive the previous maximum window of seven days in the additive migration.
The value line: **the relay is free for what makes your wallet work; you pay to use
it as your everyday Nostr relay.** Free users are never cut off from wallet features.
## Payment requirements
## 4. Data model
The primary rail uses BTCPay's Greenfield API. Additional `xmrcheckout` and
`wowcheckout` rails use compatible endpoints and their own configured credentials.
An invoice always records its originating rail, rather than polling whichever
processor is currently first in the configuration.
Migration `..._premium.sql`:
```sql
ALTER TABLE users ADD COLUMN premium_until TIMESTAMPTZ; -- NULL = never premium
-- No standalone index on premium_until: is_premium_npub keys on the existing UNIQUE
-- index on users.nostr_pubkey and `premium_until > NOW()` is a cheap per-row filter,
-- so an index here would only add write cost to every users UPDATE.
```
A **dedicated `premium_invoices` table** (not a `purpose` column on `payment_invoices`),
because premium binds on the authenticated `user_id` (a logged-in purchase) while
registration invoices bind on plaintext `pubkey_hash` — cleaner than a nullable
either-or. Columns: `invoice_id` PK, `user_id` FK (ON DELETE CASCADE), `provider`,
`plan_id`, `period_days`, `amount`, `currency`, `created_at`, `consumed_at`; a partial
`WHERE consumed_at IS NULL` index for the single-use lookup + a per-user pending cap.
BTCPay supports confirmation depths of 1, 2 or 6 through its invoice policy.
Requests for 3 through 5 round up to 6; a larger configured depth is refused.
Checkout rails also receive the exact confirmation count. Invoice creation pins
underpayment tolerance to zero, so a store's default cannot reduce the payment
required for access. Processor credentials remain server-side.
DB fns (`infra/db/premium_invoices.rs` + `users.rs`):
- `is_premium_npub(nostr_pubkey) -> bool` — `SELECT EXISTS(... WHERE nostr_pubkey = $1 AND premium_until > NOW())` (npub is public → not peppered).
- `activate_premium(invoice_id, user_id, days) -> Option<premium_until>` — consume the
single-use invoice AND extend `premium_until` in **one transaction** (so a mid-flight
failure rolls the consume back and the paid invoice stays redeemable). Extends from
`GREATEST(COALESCE(premium_until, NOW()), NOW())`, so early/longer renewals stack.
- `count_unconsumed_premium_invoices(user_id)` — bounds pending invoices per user.
## Configuration and deployment
## 5. Admission policy change
`PREMIUM_ENABLED` defaults to `false`. Enabling it requires a configured primary
payment processor, relay, `premium-post` admission policy, and valid plans.
`PREMIUM_PLANS` defines `id:days:amount` entries in `PREMIUM_CURRENCY`.
The [example configuration](../../.env.example) owns the variable names and defaults.
The [paid relay recipe](../../examples/paid-relay/README.md) illustrates independent
self-hosting; company production changes use reviewed Fleet plan/apply procedures.
The pure engine (`infra/relay/policy.rs::decide`) classifies each event and gates on
**kind × membership**. A new `WritePolicy::PremiumPost` and an `author_premium` input:
The admission service binds to loopback by default. Setting `RELAY_MODE=external`
does not by itself authorize cross-host admission access. A relocation must
establish a protected admission path and preserve the same write policy.
- **Wallet-functional kinds are free for registered users.** Define `WALLET_KINDS` —
the events that make the wallet + its peer features work: `1059` (NIP-59 gift-wrap /
encrypted DMs, incl. tip + swap-coordination payloads), `10050` (NIP-17 DM relay
list), and the tip / atomic-swap coordination kinds as those features define their
on-Nostr shape. Extensible constant.
- **`PremiumPost` decision order** in the pure engine (first match wins). The
`max_event_bytes` size cap is enforced in the `nauthz` adapter *before* the pure
engine runs (fail-closed), so it is not a branch here:
1. `author_premium` → **Permit** (any kind — the general-purpose relay).
2. `author_registered` && `kind ∈ WALLET_KINDS` → **Permit** (free wallet use).
3. `author_registered` (non-wallet kind, not premium) → **Deny** (premium required).
4. external author → gift-wrap inbox delivery only (`kind == 1059` to a registered
recipient, PoW-gated), else **Deny**.
- Existing `Open` / `AuthorAllowlist` / `InboxOutbox` unchanged.
## Verification
`nauthz.rs::event_admit` passes the event `kind`, `is_registered_npub(author)`, and
the new `is_premium_npub(author)` into `decide`. Fail-closed on error (deny).
The relevant checks cover policy decisions, configuration refusal, real invoice
request serialization, single-use activation, renewal, cross-user binding and
expired-window capacity. Database tests require a disposable PostgreSQL database;
a run that skips them does not establish payment correctness. OpenAPI is generated
from the handlers and must be regenerated when the public contract changes.
Config (`config.rs`): `RELAY_WRITE_POLICY=premium-post`; validate() keeps the
loopback-admission requirement (non-open policies need the admission service).
## Release checklist
## 6. Payment flow (recurring + tiered, reuses the PaymentProvider seam)
- `POST /premium/invoice` (JWT auth), body `{ plan }` → mint an invoice for the
selected plan's amount via the PaymentProvider, recorded in `premium_invoices`
bound to the caller's `user_id` with the plan's `days`. Returns the invoice (pay
URL / address).
- `POST /premium/activate` (JWT auth), body `{ invoice_id }` → the client-driven
settle path (pull model, no background poller): verify the invoice is `Settled` at
the processor, then `activate_premium` (single-use consume + `premium_until` extend,
in one transaction). Never holds funds — status reads only.
- `GET /premium/status` (JWT auth) → `{ active, premium_until }`.
**Tiered plans (a built-in discount feature).** `PREMIUM_PLANS` is a list
of `{ id, days, amount }`:
| id | days | price | vs. quarterly |
|---|---|---|---|
| `quarter` | 90 | $5 | — |
| `halfyear` | 182 | $9 | ~10% off |
| `year` | 365 | $15 | ~25% off |
The invoice endpoint validates `plan` against the configured list; `/capabilities`
advertises it so the client shows the tiers with the discount visible.
`extend_premium` stacks from `max(now, current_expiry)`, so buying early (or a longer
plan) just extends. Single shared `PREMIUM_CURRENCY`.
Config: `PREMIUM_ENABLED` (default false), `PREMIUM_CURRENCY`, `PREMIUM_PLANS`
(`id:days:amount` entries), reuses `PAYMENT_PROVIDER_*`. `validate()`:
`PREMIUM_ENABLED` ⇒ provider configured **and** `RELAY_ENABLED` **and**
`RELAY_WRITE_POLICY=premium-post` **and** ≥1 valid plan (fail closed otherwise).
## 6.5 Resource governance — a shared host
When the relay runs **on the same host as the wallet backend and the LWS scanners**,
it must stay within a budget and never starve them. A paid base is naturally bounded
(only paying users post general content); set hard limits regardless:
- **Size + retention** — `RELAY_MAX_EVENT_BYTES` (64 KB) and `RELAY_RETENTION_DAYS`
(30) cap storage; general (non-DM) content can carry a shorter retention than
gift-wraps if needed.
- **Connection / subscription / rate limits** — cap max connections, subscriptions
per connection, and ingest rate in the nostr-rs-relay config so a burst can't
monopolize CPU / DB / bandwidth.
- **DB isolation** — the relay uses its own tables + retention job; the wallet
backend's hot auth / LWS paths are untouched.
- **Escape hatch** — the `paid-relay` recipe ships a suggested cap set: if relay load
approaches the wallet backend's headroom, move the relay to its own host — the seam
already supports `RELAY_MODE=external`.
## 7. Capabilities + OpenAPI
- `/capabilities`: `features.premium_relay: bool` + a `premium` object
`{ currency, plans: [{ id, days, amount }], relay_url }` (present only when enabled),
so the client renders the plan tiers with the discount visible. The client greys the
**general-Nostr-posting** affordance unless `premium.active`; wallet events + DMs
stay available free.
- OpenAPI: document `POST /premium/invoice`, `POST /premium/activate`, and
`GET /premium/status`; the existing **OpenAPI no-drift check** must pass.
Capabilities **golden test** updated to include `premium_relay` + `premium`.
## 8. Free ↔ premium enforcement points (defense in depth)
1. **Relay admission (authoritative):** the gRPC hook denies non-premium *general*
writes to the relay regardless of client behavior (wallet events stay free). This
is the real gate.
2. **Client UX:** reads `premium.active` from `/premium/status` and disables the
affordance — a courtesy, never the enforcement.
The client is never trusted; a user who bypasses the UI still hits admission-deny.
## 9. Migration / compatibility
- Additive column; existing users get `premium_until = NULL` (free). No break.
- v0.2 backend unaffected. `PREMIUM_ENABLED=false` (default) → zero behavior change:
`POST /premium/invoice` and `/premium/activate` return **400** (`VALIDATION_ERROR`,
"Premium is not available on this instance."), `GET /premium/status` returns **200**
with `{ active: false, premium_until: null }`, and `/capabilities` omits the
`premium` object (`features.premium_relay: false`).
- The relay stays **off** until an operator opts in (`RELAY_ENABLED=true` +
`premium-post`) — see the `examples/paid-relay/` recipe.
## 10. Testing (Ps and Qs)
- **Unit** — `policy.rs`: PremiumPost permits a premium author (any kind), permits a
registered author's wallet kinds, denies a registered author's general post, and
delivers an external gift-wrap to a registered recipient (else denies).
- **Unit** — config fail-closed matrix (relay / policy / plans / creds / 0-conf /
days cap) and the `PREMIUM_PLANS` parser.
- **Integration** — `activate_premium` is single-use, stacks from `max(now, current)`,
and rejects a cross-user invoice; `is_premium_npub` respects expiry (npub is public,
not peppered); the premium routes are feature-gated off by default.
- **Golden** — capabilities includes `premium_relay` + `premium` when enabled, omits when off.
- **No-drift** — OpenAPI covers the three premium endpoints.
## 11. Design decisions & limitations
1. **Expiry is a hard cutoff** — no grace window; early-renewal stacking
(`activate_premium` extends from `max(now, current)`) softens it.
2. **Writes are gated, reads stay open** — read-gating would need NIP-42 AUTH plus a
premium check; a possible future addition.
3. **Prepaid periods, not auto-charge** — crypto has no card-on-file, so a member
re-pays each term or posting lapses (honest + simple).
4. **No refunds** — prepaid access to a service; stated in the recipe README / ToS.
## 12. Cookbook tie-in
`examples/paid-relay/` = `PREMIUM_ENABLED=true` + `RELAY_ENABLED=true` +
`RELAY_WRITE_POLICY=premium-post` + open+PoW registration. Its README is the
operator playbook: pricing, the NCMEC/DMCA content posture (Terms §6/§9.1),
resource expectations, and the "reads open, writes paid" decision. See
[`examples/README.md`](../../examples/README.md).
- [ ] Keep premium and additional rails disabled until explicitly configured.
- [ ] Verify the configured processor can create and read its own invoices.
- [ ] Run database regressions and payment transport tests for the exact candidate.
- [ ] Confirm paid activation, expiry and renewal with the selected processor.
- [ ] Verify free wallet events and premium general posting at relay admission.
- [ ] Describe prepaid access and the actual instance's price in client copy.
+10 -2
View File
@@ -1,4 +1,12 @@
# Legacy (v0.2.x → v0.3) user migration
# Offline legacy identity import
> Status: stable · Updated 2026-09-27 · Applies to: offline identity-import CLI
This is an offline import procedure for independently operated instances. It is
separate from the runtime, proof-bound legacy sign-in adoption implemented in
`src/infra/legacy_directory.rs` and the authentication handlers. Do not use an
offline import to merge live databases with colliding identities or handles.
Company production changes require reviewed Fleet plan/apply procedures.
`smirk-admin migrate-legacy` imports **user identity** from a legacy v0.2.x Smirk
backend database into this v0.3 backend, so users keep their handle (and their
@@ -24,7 +32,7 @@ rotation:
| `nostr_pubkey` | ✅ | preserves an already-linked Nostr identity; dropped only if that npub is already linked to a *different* v0.3 user (they re-link on v0.3) |
| `wallet_birthday`, `xmr_start_height`, `wow_start_height` | ✅ | bound the first chain scan |
| socials (telegram/discord/twitter/…) | ❌ | v0.3 identity is Nostr-native; there are no social columns to import into |
| tips / tip-links | ❌ | the v0.3 public backend has no tips subsystem |
| tips / tip-links | ❌ | v0.3 has a separate social-tips subsystem; this identity importer does not migrate tip state |
| `user_keys` / `wallets` | ❌ | the v0.3 client re-registers these at v3 derivation on first unlock; importing legacy (possibly v1/v2) keys would just plant stale rows |
`pubkey_hash` and `seed_fingerprint` are **peppered at rest** by the DB layer, so
+6 -1
View File
@@ -1,8 +1,13 @@
# Operator setup
> Status: stable · Updated 2026-09-27 · Applies to: independent backend operators
Deploy a `smirk-backend-core` instance. It holds no seed and no spend key; it
forwards view credentials to your chain backends per request and relays signed
bytes. Run your own for full privacy.
bytes. Self-hosting changes which operator receives view credentials and network
metadata; it does not remove those data flows. See [privacy boundaries](../../PRIVACY.md).
Company production deployments use reviewed Fleet plan/apply procedures rather
than the manual setup examples below.
## Prerequisites
@@ -0,0 +1,9 @@
-- Expired checkout windows must release pending-invoice slots. Retain rows so
-- an invoice paid before expiry can still be redeemed after confirmations.
ALTER TABLE premium_invoices ADD COLUMN expires_at TIMESTAMPTZ;
-- Historical rows did not record their lifetime. Seven days is the maximum
-- allowed by the existing configuration, so this cannot free a live slot early.
UPDATE premium_invoices SET expires_at = created_at + INTERVAL '7 days';
ALTER TABLE premium_invoices ALTER COLUMN expires_at SET NOT NULL;
CREATE INDEX premium_invoices_pending_expiry_idx
ON premium_invoices (user_id, expires_at) WHERE consumed_at IS NULL;
+1
View File
@@ -185,6 +185,7 @@ pub async fn invoice(
plan.days,
&plan.amount,
&currency,
pay.expires_minutes,
)
.await?;
+1 -1
View File
@@ -756,7 +756,7 @@ mod tests {
assert!(is_grin_slatepack_address(" grin1qqv9hlxpazdvs0zt "));
for c in "0123456789abcdefABCDEF".chars() {
let key: String = std::iter::repeat(c).take(64).collect();
let key: String = std::iter::repeat_n(c, 64).collect();
assert!(
!is_grin_slatepack_address(&key),
"hex public key of {c:?} must not read as an address"
+1 -1
View File
@@ -213,7 +213,7 @@ async fn list_keys(db: &Database) -> Result<(), String> {
println!(
"{} {} {:<7} added {}",
k.id,
&k.pubkey,
k.pubkey,
status,
k.created_at.to_rfc3339()
);
+25
View File
@@ -1423,6 +1423,11 @@ impl Config {
// — a settled-then-reversed free registration. An on-chain gate must
// require at least one confirmation. (A genuinely-final 0-conf rail
// like Lightning would be a different provider, not this one.)
if p.confirmations > 6 {
return Err(cfg_err(
"PAYMENT_CONFIRMATIONS exceeds BTCPay's maximum of 6 per invoice",
));
}
if p.confirmations == 0 {
return Err(cfg_err(
"PAYMENT_CONFIRMATIONS must be >= 1 (0-conf lets a settled payment be \
@@ -1539,6 +1544,11 @@ impl Config {
// block. A 0-conf invoice settles on first mempool sighting and can
// then be double-spent AWAY after the premium grant; a 0/oversize
// expiry is a malformed invoice window.
if p.confirmations > 6 {
return Err(cfg_err(
"PAYMENT_CONFIRMATIONS exceeds BTCPay's maximum of 6 per invoice",
));
}
if p.confirmations == 0 {
return Err(cfg_err(
"PAYMENT_CONFIRMATIONS must be >= 1 when PREMIUM_ENABLED is on \
@@ -2454,6 +2464,21 @@ mod tests {
assert!(c.validate().is_err());
}
#[test]
fn payment_confirmation_policy_must_be_representable() {
for confirmations in [7, 100, u32::MAX] {
let mut registration = valid();
registration.registration.payment = valid_payment();
registration.registration.payment.confirmations = confirmations;
assert!(registration.validate().is_err());
let mut premium = valid();
wire_premium(&mut premium);
premium.registration.payment.confirmations = confirmations;
assert!(premium.validate().is_err());
}
}
#[test]
fn premium_rejects_absurd_plan_days() {
let mut c = valid();
+10 -6
View File
@@ -43,11 +43,12 @@ impl Database {
period_days: i32,
amount: &str,
currency: &str,
expires_minutes: u32,
) -> Result<(), AppError> {
sqlx::query(
"INSERT INTO premium_invoices \
(invoice_id, user_id, provider, plan_id, period_days, amount, currency) \
VALUES ($1, $2, $3, $4, $5, $6, $7)",
(invoice_id, user_id, provider, plan_id, period_days, amount, currency, expires_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW() + make_interval(mins => $8))",
)
.bind(invoice_id)
.bind(user_id)
@@ -56,6 +57,10 @@ impl Database {
.bind(period_days)
.bind(amount)
.bind(currency)
.bind(
i32::try_from(expires_minutes)
.map_err(|_| AppError::ValidationError("invoice expiration is too large".into()))?,
)
.execute(self.pool())
.await?;
Ok(())
@@ -111,13 +116,12 @@ impl Database {
))
}
/// How many unconsumed premium invoices this user currently holds — bounds how
/// many outstanding invoices one authenticated user can accrue at the processor
/// (served by the partial `premium_invoices_unconsumed_idx`).
/// Count unconsumed invoices whose payment window is still open. Expiration
/// frees a slot without deleting the binding needed to redeem a paid invoice.
#[instrument(skip(self))]
pub async fn count_unconsumed_premium_invoices(&self, user_id: Uuid) -> Result<i64, AppError> {
let n = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM premium_invoices WHERE user_id = $1 AND consumed_at IS NULL",
"SELECT COUNT(*) FROM premium_invoices WHERE user_id = $1 AND consumed_at IS NULL AND expires_at > NOW()",
)
.bind(user_id)
.fetch_one(self.pool())
+63 -68
View File
@@ -436,9 +436,8 @@ impl LwsClient {
///
/// The LWS `add_account` starts every account at the chain TIP and ignores
/// any supplied start height, so a restore needs an explicit backwards
/// rescan. Read the post-add scan height and only rescan when strictly
/// lowering it — honoring the `rescan` backwards-only invariant (a rescan to
/// `height >= current` is undefined behavior in monero-lws).
/// rescan. Read its durable start height on every import so a failed backfill
/// can resume, and only rescan when strictly lowering the scan cursor.
#[instrument(skip(self, view_key), fields(net = %self.network, start_height, provision_minors))]
pub async fn import_account(
&self,
@@ -447,76 +446,35 @@ impl LwsClient {
start_height: u64,
provision_minors: u32,
) -> Result<(), AppError> {
// Only a NEWLY added account needs the backwards rescan to its birthday
// (monero-lws `add_account` starts every account at the chain tip). An
// account that ALREADY exists has already been imported from its
// birthday, so re-registration MUST be an idempotent no-op here.
//
// Rescanning an existing account resets its scan cursor to the birthday,
// so a client that re-registers on every balance poll (passing the fixed
// wallet birthday) would perpetually wipe scan progress: the account
// reads a 0 balance, slowly re-backfills, then gets reset again on the
// next poll and never catches up. A genuine re-restore to an EARLIER
// birthday goes through the explicit admin `rescan` path, not this one.
match self.account_scan_height(address).await? {
// Already registered: never reset an existing account from here.
//
// It is still brought up to the required subaddress ceiling. The
// upsert is idempotent and touches no scan state, so the
// anti-reset-loop invariant is untouched, while an account that was
// registered before provisioning was enabled (or before the ceiling
// was raised) can finally be provisioned instead of being locked out
// of the feature forever by this short-circuit.
Some(_) => {
self.provision_account0_covering(address, view_key, provision_minors)
.await?;
Ok(())
}
None => {
self.admin_add_account(address, view_key).await?;
// Provision subaddress ranges BEFORE the backwards rescan (money
// gate G3): the rescan re-scans forward from the birthday, so the
// subaddresses must ALREADY be registered for the LWS to attribute
// historical subaddress receipts in that backfill.
//
// The result is HELD, not propagated with `?`. The account now
// exists at the LWS, so returning early here would strand it at
// the chain tip: every later re-registration takes the `Some(_)`
// branch above, answers 200, and never runs the backwards scan,
// leaving the wallet reading a zero balance forever. The rescan
// therefore always runs, and the provisioning failure is surfaced
// afterwards.
let provisioned = self
.provision_account0_covering(address, view_key, provision_minors)
.await;
let rescanned = self.rescan_back_to(address, start_height).await;
// A failed backfill is the graver of the two (it is the one a
// retry can no longer reach), so it is reported first; otherwise
// the provisioning failure is surfaced, never swallowed.
rescanned?;
provisioned?;
Ok(())
}
if self.account_scan_height(address).await?.is_none() {
self.admin_add_account(address, view_key).await?;
}
// Provision before backfilling so historical subaddress receipts can be
// attributed. A provisioning error must not strand the primary address
// at the tip, so attempt both operations and report either failure.
let provisioned = self
.provision_account0_covering(address, view_key, provision_minors)
.await;
self.rescan_back_to(address, view_key, start_height).await?;
provisioned?;
Ok(())
}
/// Lower a freshly added account's scan cursor to `start_height`, retrying a
/// transient failure. A no-op when the account is already at or below it
/// (the `rescan` backwards-only invariant).
async fn rescan_back_to(&self, address: &str, start_height: u64) -> Result<(), AppError> {
let current = self.account_scan_height(address).await?.unwrap_or(u64::MAX);
if start_height >= current {
return Ok(());
}
// The account is added at the chain tip. If this backfill rescan fails,
// the account is stranded at the tip: it reads a 0 balance and the
// `Some(_)` short-circuit in `import_account` (which exists to prevent
// the reset loop) means a later re-registration will NOT retry it. So
// retry a transient failure here, where we still know this is a fresh
// account that owes a backwards scan.
/// Complete a restore only when the LWS's durable start height covers it.
/// Account existence alone does not prove that an earlier backfill worked.
/// Once start_height is lowered, later imports preserve scan progress.
async fn rescan_back_to(
&self,
address: &str,
view_key: &str,
start_height: u64,
) -> Result<(), AppError> {
let mut attempt = 0u32;
loop {
match self.rescan(vec![address.to_string()], start_height).await {
let result = self
.ensure_restore_range(address, view_key, start_height)
.await;
match result {
Ok(()) => return Ok(()),
Err(e) => {
attempt += 1;
@@ -530,6 +488,43 @@ impl LwsClient {
}
}
async fn ensure_restore_range(
&self,
address: &str,
view_key: &str,
start_height: u64,
) -> Result<(), AppError> {
// These fields are required here. The balance response's compatibility
// defaults cannot stand in for evidence that a restore has completed.
#[derive(Deserialize)]
struct ScanRange {
#[serde(deserialize_with = "string_or_u64::deserialize")]
start_height: u64,
#[serde(deserialize_with = "string_or_u64::deserialize")]
scanned_height: u64,
}
let body = GetAddressInfoRequest {
address: address.to_string(),
view_key: view_key.to_string(),
};
let url = format!("{}/get_address_info", self.user_url);
let before: ScanRange = self
.post_json(url.clone(), "get_address_info", &body)
.await?;
if before.start_height <= start_height {
return Ok(());
}
if start_height >= before.scanned_height {
return Err(self.node_err("rescan", "restore range has an inconsistent scan cursor"));
}
self.rescan(vec![address.to_string()], start_height).await?;
let after: ScanRange = self.post_json(url, "get_address_info", &body).await?;
if after.start_height > start_height {
return Err(self.node_err("rescan", "LWS did not confirm the requested restore range"));
}
Ok(())
}
async fn admin_add_account(&self, address: &str, view_key: &str) -> Result<(), AppError> {
let url = format!("{}/add_account", self.admin_url);
let body = AdminAddAccountRequest {
+80 -16
View File
@@ -93,15 +93,17 @@ impl BtcPayProvider {
}
/// Map the operator's numeric confirmations-to-finalize onto BTCPay's
/// coarser `speedPolicy` buckets. BTCPay expresses finality as a policy, not
/// an arbitrary N, so this rounds UP to the nearest bucket (an operator
/// needing an exact/deeper count sets it on the processor merchant default).
fn speed_policy(confirmations: u32) -> &'static str {
/// coarser `speedPolicy` buckets without weakening the requested depth.
/// BTCPay cannot express more than six confirmations on an invoice.
fn speed_policy(confirmations: u32) -> Result<&'static str, AppError> {
match confirmations {
0 => "HighSpeed", // 0-conf
1 => "MediumSpeed", // 1 conf
2..=5 => "LowMediumSpeed", // 2 conf
_ => "LowSpeed", // 6 conf
0 => Ok("HighSpeed"),
1 => Ok("MediumSpeed"),
2 => Ok("LowMediumSpeed"),
3..=6 => Ok("LowSpeed"),
_ => Err(AppError::ConfigError(
"BTCPay supports at most six confirmations per invoice".into(),
)),
}
}
@@ -208,8 +210,15 @@ impl PaymentProvider for BtcPayProvider {
currency: &req.currency,
metadata: serde_json::json!({ "smirkBind": req.bind }),
checkout: BtcPayCheckout {
speed_policy: Self::speed_policy(req.confirmations),
// Checkout rails enforce the exact count independently. Their
// compatibility speedPolicy remains within BTCPay's vocabulary.
speed_policy: Self::speed_policy(if self.exact_confirmations {
req.confirmations.min(6)
} else {
req.confirmations
})?,
expiration_minutes: req.expires_minutes,
payment_tolerance: 0,
confirmations_required: self.exact_confirmations.then_some(req.confirmations),
},
};
@@ -246,6 +255,8 @@ struct BtcPayCreateReq<'a> {
struct BtcPayCheckout {
speed_policy: &'static str,
expiration_minutes: u32,
// Override a store's underpayment tolerance: only full payment grants access.
payment_tolerance: u8,
#[serde(skip_serializing_if = "Option::is_none")]
confirmations_required: Option<u32>,
}
@@ -309,6 +320,49 @@ mod tests {
assert_eq!(p.base_url, "https://pay.example.org");
}
#[tokio::test]
async fn invoice_request_preserves_finality_and_requires_full_payment() {
use axum::{routing::post, Json, Router};
use std::sync::{Arc, Mutex};
let seen = Arc::new(Mutex::new(None));
let capture = seen.clone();
let router = Router::new().route(
"/api/v1/stores/store-123/invoices",
post(move |Json(body): Json<serde_json::Value>| async move {
*capture.lock().unwrap() = Some(body);
Json(serde_json::json!({
"id": "test-invoice", "status": "New",
"checkoutLink": "https://pay.example.org/invoice/test-invoice"
}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = cfg();
config.provider_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
let provider = BtcPayProvider::new(&config).unwrap();
provider
.create_invoice(&InvoiceRequest {
amount: "5".into(),
currency: "USD".into(),
confirmations: 5,
bind: "test-user".into(),
expires_minutes: 60,
})
.await
.unwrap();
server.abort();
let body = seen
.lock()
.unwrap()
.take()
.expect("processor received a request");
assert_eq!(body["checkout"]["speedPolicy"], "LowSpeed");
assert_eq!(body["checkout"]["paymentTolerance"], 0);
assert_eq!(body["amount"], "5");
}
#[test]
fn api_key_is_redacted_in_debug() {
let p = BtcPayProvider::new(&cfg()).unwrap();
@@ -317,13 +371,20 @@ mod tests {
}
#[test]
fn speed_policy_buckets() {
assert_eq!(BtcPayProvider::speed_policy(0), "HighSpeed");
assert_eq!(BtcPayProvider::speed_policy(1), "MediumSpeed");
assert_eq!(BtcPayProvider::speed_policy(2), "LowMediumSpeed");
assert_eq!(BtcPayProvider::speed_policy(5), "LowMediumSpeed");
assert_eq!(BtcPayProvider::speed_policy(6), "LowSpeed");
assert_eq!(BtcPayProvider::speed_policy(100), "LowSpeed");
fn speed_policy_never_weakens_confirmation_depth() {
for requested in 0..=6 {
let actual = match BtcPayProvider::speed_policy(requested).unwrap() {
"HighSpeed" => 0,
"MediumSpeed" => 1,
"LowMediumSpeed" => 2,
"LowSpeed" => 6,
other => panic!("unknown policy: {other}"),
};
assert!(actual >= requested);
}
for unsupported in [7, 100, u32::MAX] {
assert!(BtcPayProvider::speed_policy(unsupported).is_err());
}
}
#[test]
@@ -398,6 +459,7 @@ mod tests {
checkout: BtcPayCheckout {
speed_policy: "MediumSpeed",
expiration_minutes: 60,
payment_tolerance: 0,
confirmations_required: None,
},
};
@@ -416,6 +478,7 @@ mod tests {
let c = BtcPayCheckout {
speed_policy: "MediumSpeed",
expiration_minutes: 60,
payment_tolerance: 0,
confirmations_required: None,
};
let v = serde_json::to_value(&c).unwrap();
@@ -427,6 +490,7 @@ mod tests {
let c = BtcPayCheckout {
speed_policy: "LowMediumSpeed",
expiration_minutes: 60,
payment_tolerance: 0,
confirmations_required: Some(3),
};
let v = serde_json::to_value(&c).unwrap();
+201
View File
@@ -0,0 +1,201 @@
//! Fundless restore regressions against a local, stateful LWS double.
use std::sync::{Arc, Mutex};
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde_json::{json, Value};
use smirk_backend_core::{config::LwsConfig, infra::lws::LwsClient};
const ADDRESS: &str = "test-wallet-address";
const VIEW_KEY: &str = "test-view-key";
#[derive(Default)]
struct Account {
present: bool,
start: u64,
scanned: u64,
failures: usize,
lost_ack: bool,
omit_range: bool,
rescans: Vec<u64>,
}
type Shared = Arc<Mutex<Account>>;
async fn accounts(State(state): State<Shared>) -> Json<Value> {
let account = state.lock().unwrap();
Json(json!({"active": if account.present {
vec![json!({"address": ADDRESS, "scan_height": account.scanned})]
} else { vec![] }}))
}
async fn add(State(state): State<Shared>) -> Json<Value> {
let mut account = state.lock().unwrap();
account.present = true;
account.start = 600;
account.scanned = 600;
Json(json!({"updated": [ADDRESS]}))
}
async fn info(State(state): State<Shared>) -> Json<Value> {
let account = state.lock().unwrap();
Json(if account.omit_range {
json!({})
} else {
json!({"start_height": account.start, "scanned_height": account.scanned})
})
}
async fn rescan(State(state): State<Shared>, Json(body): Json<Value>) -> (StatusCode, Json<Value>) {
let mut account = state.lock().unwrap();
let height = body["params"]["height"].as_u64().unwrap();
assert!(
height < account.scanned,
"rescan must lower the current cursor"
);
account.rescans.push(height);
if account.failures > 0 {
account.failures -= 1;
return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({})));
}
account.start = account.start.min(height);
account.scanned = height;
let status = if account.lost_ack {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::OK
};
(status, Json(json!({"updated": [ADDRESS]})))
}
struct Harness {
state: Shared,
cfg: LwsConfig,
server: tokio::task::JoinHandle<()>,
}
impl Harness {
async fn new(account: Account) -> Self {
let state = Arc::new(Mutex::new(account));
let router = Router::new()
.route("/list_accounts", post(accounts))
.route("/add_account", post(add))
.route("/get_address_info", post(info))
.route("/rescan", post(rescan))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
Self {
state,
cfg: LwsConfig {
lws_url: url.clone(),
lws_admin_url: url,
lws_admin_key: "test-admin-key".into(),
daemon_url: String::new(),
},
server,
}
}
fn client(&self) -> LwsClient {
LwsClient::monero(&self.cfg).unwrap()
}
}
impl Drop for Harness {
fn drop(&mut self) {
self.server.abort();
}
}
#[tokio::test]
async fn failed_initial_backfill_can_resume_after_backend_restart() {
let h = Harness::new(Account {
failures: 3,
..Account::default()
})
.await;
assert!(h
.client()
.import_account(ADDRESS, VIEW_KEY, 100, 0)
.await
.is_err());
assert!(h.state.lock().unwrap().present);
assert_eq!(h.state.lock().unwrap().start, 600);
// A fresh client has no process-local memory of the failed add/rescan pair.
h.client()
.import_account(ADDRESS, VIEW_KEY, 100, 0)
.await
.unwrap();
assert_eq!(h.state.lock().unwrap().start, 100);
let rescans = h.state.lock().unwrap().rescans.clone();
h.state.lock().unwrap().scanned = 550;
h.client()
.import_account(ADDRESS, VIEW_KEY, 100, 0)
.await
.unwrap();
let account = h.state.lock().unwrap();
assert_eq!(
account.scanned, 550,
"routine re-registration retains progress"
);
assert_eq!(
account.rescans, rescans,
"completed backfills are not replayed"
);
}
#[tokio::test]
async fn lost_rescan_response_is_reconciled_before_retrying() {
let h = Harness::new(Account {
lost_ack: true,
..Account::default()
})
.await;
h.client()
.import_account(ADDRESS, VIEW_KEY, 100, 0)
.await
.unwrap();
let account = h.state.lock().unwrap();
assert_eq!(account.start, 100);
assert_eq!(
account.rescans,
vec![100],
"uncertain success must not reset progress twice"
);
}
#[tokio::test]
async fn missing_range_is_unknown_and_cannot_authorize_a_rescan() {
let h = Harness::new(Account {
present: true,
start: 600,
scanned: 600,
omit_range: true,
..Account::default()
})
.await;
assert!(h
.client()
.import_account(ADDRESS, VIEW_KEY, 100, 0)
.await
.is_err());
assert!(h.state.lock().unwrap().rescans.is_empty());
}
#[tokio::test]
async fn already_covered_restore_never_moves_the_cursor_forward() {
let h = Harness::new(Account {
present: true,
start: 50,
scanned: 75,
..Account::default()
})
.await;
h.client()
.import_account(ADDRESS, VIEW_KEY, 100, 0)
.await
.unwrap();
let account = h.state.lock().unwrap();
assert_eq!(account.scanned, 75);
assert!(account.rescans.is_empty());
}
+32 -3
View File
@@ -33,10 +33,10 @@ async fn activate_premium_is_single_use_and_stacks() {
// Unique per run so the suite is re-runnable against a persistent test DB.
let inv_a = format!("inv-a-{}", Uuid::new_v4());
let inv_b = format!("inv-b-{}", Uuid::new_v4());
db.insert_premium_invoice(&inv_a, user_id, "btcpay", "quarter", 90, "5", "USD")
db.insert_premium_invoice(&inv_a, user_id, "btcpay", "quarter", 90, "5", "USD", 60)
.await
.unwrap();
db.insert_premium_invoice(&inv_b, user_id, "btcpay", "quarter", 90, "5", "USD")
db.insert_premium_invoice(&inv_b, user_id, "btcpay", "quarter", 90, "5", "USD", 60)
.await
.unwrap();
assert_eq!(
@@ -81,7 +81,7 @@ async fn activate_premium_rejects_cross_user_invoice() {
let owner = app.create_user().await;
let attacker = app.create_user().await;
let inv_x = format!("inv-x-{}", Uuid::new_v4());
db.insert_premium_invoice(&inv_x, owner, "btcpay", "quarter", 90, "5", "USD")
db.insert_premium_invoice(&inv_x, owner, "btcpay", "quarter", 90, "5", "USD", 60)
.await
.unwrap();
@@ -155,3 +155,32 @@ async fn premium_endpoints_gated_off_by_default() {
.await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn expired_checkout_frees_capacity_without_losing_paid_redemption() {
let app = require_app!();
let db = &app.state.db;
let user = app.create_user().await;
let expired = format!("expired-{}", Uuid::new_v4());
let active = format!("active-{}", Uuid::new_v4());
db.insert_premium_invoice(&expired, user, "btcpay", "year", 365, "5", "USD", 60)
.await
.unwrap();
db.insert_premium_invoice(&active, user, "btcpay", "year", 365, "5", "USD", 60)
.await
.unwrap();
let before = db.count_unconsumed_premium_invoices(user).await.unwrap();
sqlx::query("UPDATE premium_invoices SET expires_at = NOW() - INTERVAL '1 second' WHERE invoice_id = $1")
.bind(&expired).execute(db.pool()).await.unwrap();
let after = db.count_unconsumed_premium_invoices(user).await.unwrap();
assert!(after < before, "expired checkout releases its pending slot");
assert!(after > 0, "live checkout still occupies its slot");
assert!(db.get_premium_invoice(&expired).await.unwrap().is_some());
// The activation endpoint still requires processor settlement. Once that
// proof arrives, checkout expiration must not destroy a paid entitlement.
assert!(db
.activate_premium(&expired, user, 365)
.await
.unwrap()
.is_some());
}
+10 -4
View File
@@ -11,10 +11,15 @@
set -uo pipefail
cd "$(dirname "$0")/.."
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -n "$(git ls-files)" ]; then
mapfile -t FILES < <(git ls-files)
FILES=()
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
while IFS= read -r -d '' file; do
FILES+=("$file")
done < <(git ls-files -z --cached --others --exclude-standard)
else
mapfile -t FILES < <(find . -type f -not -path './.git/*' -not -path './target/*' -printf '%P\n')
while IFS= read -r -d '' file; do
FILES+=("$file")
done < <(find . -type f -not -path './.git/*' -not -path './target/*' -print0)
fi
# Secret shapes (not names), covering the classes this backend actually handles:
@@ -41,7 +46,8 @@ for f in "${FILES[@]}"; do
case "$f" in tools/secret-scan.sh) continue ;; esac
if grep -EnI "$PATTERNS" "$f" >/dev/null 2>&1; then
echo "SECRET-SCAN: possible key/seed material in $f"
grep -EnI "$PATTERNS" "$f" | sed 's/^/ /'
# Report locations without echoing the secret-shaped value we caught.
grep -EnI "$PATTERNS" "$f" | cut -d: -f1 | sed 's/^/ line /'
fail=1
fi
done