mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
fix(wallet): resume restores and preserve invoice payment policy
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -185,6 +185,7 @@ pub async fn invoice(
|
||||
plan.days,
|
||||
&plan.amount,
|
||||
¤cy,
|
||||
pay.expires_minutes,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user