diff --git a/.env.example b/.env.example index eb2db48..29297cc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21f2902..c3a0a25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/PRIVACY.md b/PRIVACY.md index b754b25..42716cf 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -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. diff --git a/README.md b/README.md index 7b49fc3..39dea37 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/design/premium-relay.md b/docs/design/premium-relay.md index 166f78b..f4e5d4e 100644 --- a/docs/design/premium-relay.md +++ b/docs/design/premium-relay.md @@ -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` — 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. diff --git a/docs/operations/LEGACY_MIGRATION.md b/docs/operations/LEGACY_MIGRATION.md index 89689af..41a2028 100644 --- a/docs/operations/LEGACY_MIGRATION.md +++ b/docs/operations/LEGACY_MIGRATION.md @@ -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 diff --git a/docs/operations/OPERATOR_SETUP.md b/docs/operations/OPERATOR_SETUP.md index 9f4e38a..83a4e05 100644 --- a/docs/operations/OPERATOR_SETUP.md +++ b/docs/operations/OPERATOR_SETUP.md @@ -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 diff --git a/migrations/20260927000001_premium_invoice_expiry.sql b/migrations/20260927000001_premium_invoice_expiry.sql new file mode 100644 index 0000000..4355831 --- /dev/null +++ b/migrations/20260927000001_premium_invoice_expiry.sql @@ -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; diff --git a/src/api/premium.rs b/src/api/premium.rs index 93e911d..a941b23 100644 --- a/src/api/premium.rs +++ b/src/api/premium.rs @@ -185,6 +185,7 @@ pub async fn invoice( plan.days, &plan.amount, ¤cy, + pay.expires_minutes, ) .await?; diff --git a/src/api/users.rs b/src/api/users.rs index 268a93f..2098104 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -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" diff --git a/src/bin/smirk-admin.rs b/src/bin/smirk-admin.rs index c81715c..946985a 100644 --- a/src/bin/smirk-admin.rs +++ b/src/bin/smirk-admin.rs @@ -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() ); diff --git a/src/config.rs b/src/config.rs index c1e2c44..e568140 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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(); diff --git a/src/infra/db/premium_invoices.rs b/src/infra/db/premium_invoices.rs index 162f0e9..3789f96 100644 --- a/src/infra/db/premium_invoices.rs +++ b/src/infra/db/premium_invoices.rs @@ -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 { 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()) diff --git a/src/infra/lws/client.rs b/src/infra/lws/client.rs index d348628..a6b7b98 100644 --- a/src/infra/lws/client.rs +++ b/src/infra/lws/client.rs @@ -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 { diff --git a/src/infra/payment/btcpay.rs b/src/infra/payment/btcpay.rs index 1da8823..6f67474 100644 --- a/src/infra/payment/btcpay.rs +++ b/src/infra/payment/btcpay.rs @@ -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, } @@ -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| 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(); diff --git a/tests/lws_restore.rs b/tests/lws_restore.rs new file mode 100644 index 0000000..e202753 --- /dev/null +++ b/tests/lws_restore.rs @@ -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, +} + +type Shared = Arc>; + +async fn accounts(State(state): State) -> Json { + 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) -> Json { + 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) -> Json { + 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, Json(body): Json) -> (StatusCode, Json) { + 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()); +} diff --git a/tests/premium.rs b/tests/premium.rs index 95cdfe9..4a3b2fc 100644 --- a/tests/premium.rs +++ b/tests/premium.rs @@ -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()); +} diff --git a/tools/secret-scan.sh b/tools/secret-scan.sh index 8316802..52c8d56 100755 --- a/tools/secret-scan.sh +++ b/tools/secret-scan.sh @@ -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