Skip to content

Commit d0ec58b

Browse files
DashKash54claude
andauthored
feat(auto-topup): Stripe auto top-up for opted-in users (#448)
* docs(billing): add auto top-up implementation plan Self-contained design doc covering goal, architecture, Postgres schema, endpoint topology (dashboard endpoints behind existing billing_auth.rs on lit-api-server, lit-payments internal-only), trigger handler flow, webhook handler with raw-body HMAC verification, three-layer concurrency model (mutex + Stripe Idempotency-Key + Postgres unique constraint), edge cases, build order, and explicit soft-cap trade-offs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): add implementation phases and local testing to auto top-up plan Replaces the 9-item build order with an 8-phase breakdown including dependency chain, per-phase tasks, time estimates, gates, and parallelization notes. Adds a fully self-contained local development & testing section covering Postgres via Docker, env var templates for both services, Stripe CLI webhook forwarding, the full test-card matrix, and per-phase acceptance checklists. Adds a handoff checklist so an implementing agent can pick up the doc cold. Records the Stripe Billing Meters alternative as considered and rejected, with the relevant Stripe doc link. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): spec SCA recovery flow for auto top-up Adds the dashboard resolution path for off-session PaymentIntents that return authentication_required (3DS/SCA). Previously the plan only said "user re-authenticates next visit" without describing how. Now spelled out: - Two new columns on auto_topup_config: pending_action_pi_id, pending_action_at - New endpoint POST /billing/auto_topup_resume_pending returns the pending PI's client_secret so the dashboard can call stripe.handleNextAction - Trigger handler sets the pending fields and sends the "action required" email when Stripe returns authentication_required - Webhook handler clears the pending fields on payment_intent.succeeded or payment_intent.payment_failed for the matching PI - Three email templates listed (action required, payment failed, auto-disable) - Phase 6 dashboard tasks updated; Phase 6 gate now includes the SCA path - New SCA test scenarios added to the local testing checklist Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): rewrite auto top-up plan for webhook-driven architecture Replaces the internal-trigger architecture with a webhook-driven one, empirically validated against Stripe test mode. Key changes: - Trigger source: Stripe customer.updated webhook (filtered by previous_attributes.balance) instead of an internal HTTP call from lit-api-server after every deduction. - lit-api-server is unchanged except for one tiny internal endpoint: POST /internal/invalidate_balance_cache. - All dashboard-facing endpoints and webhook handlers live on lit-payments. Auth uses the existing billing_auth module extracted from lit-api-server into a shared crate so lit-payments can verify wallet sig + API key identically. - Empirical validation included in section 20: 21 balance_transactions fired against a test customer (slow loop + parallel burst) produced exactly 21 customer.updated events, 1:1, no coalescing, balance correct in every payload. - New 9-phase build plan reflecting the simplified scope (foundation, auth extraction, setup_intent, config CRUD, trigger handler, credit webhook, dashboard UI, hardening, rollout). - Updated local testing section with the new webhook subscription set and per-phase acceptance criteria. Net architecture: TEE narrowed to key-usage only. lit-payments owns all auto-top-up logic. Two new Postgres tables (auto_topup_config, auto_topup_credits) unchanged from prior plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): switch auto top-up to sync-credit design Replaces the webhook-driven credit path with synchronous charge-and-credit inside the customer.updated handler. Reconciler cron becomes load-bearing as the recovery path for HTTP timeouts and partial writes. Key changes from prior version: - Drop payment_intent.succeeded and payment_intent.payment_failed webhooks. Only customer.updated remains subscribed. - Sync flow in the trigger handler: paymentIntents.create returns succeeded synchronously for US cards, then immediately INSERT auto_topup_credits + balance_transactions credit + cache invalidation. - Failure handling moves to the synchronous response branches: declined, expired, insufficient_funds, requires_action all send email and exit. - Reconciliation cron added (default 15-min interval). Scans recent succeeded auto-topup PIs and recovers any missing credit rows or NULL balance_transaction_ids using the same idempotency keys. - Partial index on auto_topup_credits.stripe_balance_transaction_id IS NULL for fast reconciler scans. - Drops SCA recovery flow entirely. US cards only. requires_action treated as failure with a "card requires authentication" email. - Schema: removes pending_action_pi_id and pending_action_at columns since there's no SCA resume flow now. - New env var RECONCILER_INTERVAL_SECS (default 900). - Phase 5 becomes the load-bearing webhook+sync-credit phase. New Phase 6 is reconciler implementation with explicit kill-and-recover acceptance test. Total still ~2 weeks. Validated by Codex consult: sync credit is defensible under US-card + no-SCA + accept-rare-manual-recovery constraints, provided idempotency keys on the credit write and a reconciler are in place. Both are in the design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): drop recent-PI short-circuit, add dashboard banner to error paths Removes the 10-minute recent-PI check from the trigger handler per explicit product preference: never miss a legitimate top-up. The mutex, fresh balance fetch, and cap check together still prevent duplicate processing in normal cases. The rare case where customer.balance briefly lags after a credit could result in an extra top-up, which aligns with the "bias toward more top-ups, never less" stance. Also clarifies that requires_action and decline paths send both an email and a dashboard banner (not email only). Notes in §13 that the existing one-time top-up flow does support SCA cards because the user is on-session in the dashboard; only auto-top-up is US-only due to off-session constraints. Renumbered trigger-handler steps 8-15 to 8-14 after removing the recent-PI step. Updated sequence diagram in §16 and phase 5 task description to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(billing): add SCA recovery flow (EU card support without webhooks) Adds full SCA / 3DS support for auto-top-up while keeping the sync-credit + reconciler architecture. The recovery uses on-session stripe.confirmCardPayment which returns synchronously after the bank's 3DS challenge — no payment_intent.succeeded webhook needed. Three-stage SCA story: 1. Save card with stripe.confirmCardSetup (already in Phase 3) creates the MIT prior-auth record. Most future off-session charges skip 3DS. 2. Off-session charge with confirm=true + off_session=true. Issuer often grants exemption. 3. When issuer demands SCA anyway: paymentIntents.create errors with authentication_required. We save pending_action_pi_id + a one-time recovery_token on auto_topup_config, send the user a tokenized email link. User clicks link → dashboard recovery page calls GET /billing/auto_topup_resume?token=... → backend returns the PI's client_secret → dashboard runs stripe.confirmCardPayment which renders the bank's 3DS challenge inside Stripe's iframe → confirmCardPayment returns synchronously → dashboard calls POST /billing/auto_topup_resume/complete → backend credits via the normal sync path and clears the pending state. Schema additions to auto_topup_config: - pending_action_pi_id, pending_action_at (resume target) - recovery_token, recovery_token_expires_at (single-use, 24h TTL) New endpoints on lit-payments: - GET /billing/auto_topup_resume?token=... (token → client_secret) - POST /billing/auto_topup_resume/complete (sync credit after 3DS) Other doc updates: - Sequence diagrams: setup flow now shows 3DS prior-auth, new SCA recovery diagram. - Phase 3 gate now includes SCA test card; Phase 7 gate adds the recovery flow test. - Edge cases: authentication_required row now describes the recovery flow instead of "treated as failure." - Trade-offs: removed "EU cards not supported" row; added "user abandons SCA recovery" row. About 1 day of additional implementation work, mostly the recovery page and email template. No new webhooks, no architectural changes to sync + reconciler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(lit-payments): add concise auto top-up architecture section to README Adds a self-contained summary of the auto top-up design at the top of lit-payments/README.md: components and their roles, where data lives, component interaction diagram, left-to-right runtime flow, three-layer dedup model, and key invariants. Points to plans/auto-top-up.md for the full detail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auto-topup): phase 1 foundation — schema, env vars, cache-invalidation endpoint * Postgres migration: auto_topup_config + auto_topup_credits with CHECK constraint + 2 partial indexes (reconciler null-bt_id scan; SCA recovery_token lookup). * lit-payments config: LIT_API_SERVER_BASE_URL, LIT_INTERNAL_SHARED_SECRET, STRIPE_WEBHOOK_SECRET, optional RECONCILER_INTERVAL_SECS. Outbound reqwest helper for the Phase 5 cache-invalidation callback. * lit-api-server: X-Internal-Secret Rocket guard (constant-time compare via subtle::ConstantTimeEq) + POST /internal/invalidate_balance_cache endpoint behind it. Public StripeState::invalidate_balance_cache shim. Tests: 4 Rocket integration tests using rocket::local::asynchronous::Client. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auto-topup): phase 2 — shared lit-billing-auth crate + lit-payments wiring Extracts the BillingAuth Rocket guard into a new lit-billing-auth crate so both lit-api-server and lit-payments authenticate with identical headers (EIP-712 wallet signature OR API key). Per-service plumbing is supplied via an AuthResolver trait pulled from Rocket state. * lit-billing-auth: new crate, ~600 lines incl. tests. Holds the BillingAuth enum, the request guard with full CPL-285 / CPL-286 hardening (rejects precomputed-hash shapes in API-key headers), and the AuthResolver trait. is_precomputed_hash_shape lives here (self-contained copy from utils/parse_with_hash.rs, no ApiStatus dep). Behind a default-off `openapi` feature, ships the OpenApiFromRequest impl for okapi consumers. * lit-api-server: deletes core/v1/guards/billing_auth.rs (the new crate is the source of truth). New auth_resolver::LocalAuthResolver bridges the trait to the existing in-process eip712.rs verifier and the on-chain accounts::get_billing_wallet_address resolver. Two new internal endpoints for lit-payments to delegate auth verification: - POST /internal/verify_wallet_auth - POST /internal/resolve_api_key Both behind the X-Internal-Secret guard. 216→223 lib tests (existing flows still pass — full regression sweep clean). * lit-payments: new auth_resolver::HttpAuthResolver forwards verification to lit-api-server over the existing X-Internal-Secret channel — keeps the EIP-712 verifier and on-chain plumbing in exactly one place (the TEE). Adds throwaway GET /_authping behind the guard so the wiring is reachable end-to-end without dashboard work. Tests added: * lit-billing-auth: 18 unit + integration tests with a mock AuthResolver covering the full guard decision tree (valid wallet sig, BadCredentials, Transient, malformed-header fallthrough, valid API key both Bearer and X-Api-Key, CPL-285 precomputed-hash rejection in both header positions, empty key, no headers, missing resolver fails closed). * lit-api-server: 7 new integration tests on verify_wallet_auth / resolve_api_key (auth required, success returns identity, BadCredentials → 401, Transient → 503). Architectural deviation from plan §17 (option B in discussion): the on-chain `allApiKeyHashesToMaster` resolver stays in lit-api-server rather than moving into the shared crate, since it pulls in TEE-specific plumbing (signer_pool, chain bindings). lit-payments delegates via the new internal endpoints instead. Same end-user behavior; avoids ~800 lines of unrelated code moving into the lean side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): phase 2 P1 — guard resolves api key + classify resolver errors * feat(auto-topup): phase 3 — POST /billing/setup_intent on lit-payments Dashboard save-card flow entry point. Behind the lit-billing-auth guard, returns a Stripe SetupIntent client_secret + publishable_key so the dashboard can run stripe.confirmCardSetup (3DS prior-auth happens client-side in Phase 8). * POST /billing/setup_intent: - Wallet-sig callers: resolved wallet → find_by_wallet on Stripe. If no customer: 400 "make a manual top-up first" (deliberate bootstrap requirement — does not auto-create customers, matches plan §5). If customer: create SetupIntent (usage=off_session) → return client_secret. - API-key callers: 501 (Phase 3 ships wallet-sig only; API-key resolver hop lands in a later phase if needed for dashboard). - Stripe lookup / SetupIntent failures: 503 (retriable) not 400. * lit-payments Config gains STRIPE_PUBLISHABLE_KEY (plan §14). * lit-billing-core::StripeClient pins Stripe-Version: 2020-08-27 globally. Required because Customer Search needs >=2020-08-27 and the test account's default was 2020-03-02. Pinning protects against drift from Stripe's per-account default version. Tests (3 new, real-Stripe-backed, skip silently if STRIPE_SECRET_KEY absent): * setup_intent_returns_client_secret_when_customer_exists — uses dedup-safe ensure_unique_customer helper to handle search-lag duplicate-creation races. Verifies returned SetupIntent has usage=off_session and points at the right customer. * setup_intent_returns_400_when_no_customer — wallet with no Stripe customer yields the bootstrap error. * setup_intent_returns_501_for_api_key_caller — explicit unsupported path. Regression: 47 lit-payments tests + 223 lit-api-server lib + 14 integration + 18 lit-billing-auth + 15 lit-billing-core = 317 tests, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auto-topup): phase 4 — GET/PUT /billing/auto_topup_config + validation Adds the dashboard CRUD endpoints for the per-user auto top-up rule: * lit-payments/src/auto_topup/{mod,types,db}.rs — Postgres model + queries for `auto_topup_config`. UPSERT respects the CHECK constraint; the disable transition NULLs `pending_action_pi_id`, `pending_action_at`, `recovery_token`, `recovery_token_expires_at` so a re-enable can't re-trigger a charge against stale SCA state (closes codex gap #15). * lit-payments/src/billing/auto_topup_config.rs — GET + PUT handlers behind BillingAuth (wallet-sig only; API-key returns 501 until the on-chain resolver hop lands in Phase 5). - Server-side validation: cap >= topup_amount, topup >= $5 (MIN_TOPUP_CENTS), threshold > 0, payment_method_id present, consent present. DB CHECK constraint covers the same invariants as a belt-and-braces. - Cross-tenant guard: GET /v1/payment_methods?customer=cus_xxx confirms the requested pm_xxx belongs to the caller (closes codex gap #14). - Map sqlx error code 23514 → 400 invalid_config; other DB errors → 503. Tests added (9 new integration tests, real Stripe test mode + real local Postgres, gated on STRIPE_SECRET_KEY and DATABASE_URL env vars — silent skip if absent): - GET returns null when no row exists - PUT enabled=false roundtrips through GET with disabled_reason='manual' - PUT enabled=true with all fields persists every column - PUT enabled=true with cap < topup → 400 - PUT enabled=true with topup < $5 floor → 400 - PUT enabled=true with null threshold → 400 - PUT with pm_xxx owned by a different customer → 400 - Disable transition clears pending_action_pi_id + recovery_token - API-key caller → 501 Test infrastructure: * serial_test dev-dep added; all Stripe/DB-touching tests use `#[serial]` to prevent the search-lag-duplicate-creation race the Phase 4 development cycle exposed. * `ensure_unique_customer` stopped deleting "duplicate" Stripe customers — that was producing orphan auto_topup_config rows under UNIQUE(wallet_address). The helper now accepts whichever id Stripe search returns first and trusts test order. * reset_config_row_for_wallet keys cleanup by wallet_address instead of customer_id so any prior-run orphan row is purged before each test. Regression: 56 lit-payments tests (47 + 9 new) + 18 lit-billing-auth + 15 lit-billing-core + 223 lit-api-server lib + 14 integration = 326 tests, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): phase 4 P1 — upsert preserves pending SCA fields on enabled=true * feat(auto-topup): phase 5 — customer.updated webhook + sync charge + sync credit Load-bearing trigger phase. Implements the full §6 flow synchronously inside the webhook request: HMAC verification → quick exits → per-customer mutex → fresh balance fetch → PI list / failure / cap → off-session PaymentIntent create → INSERT auto_topup_credits ON CONFLICT DO NOTHING → balance_transactions write with credit:{pi.id} idempotency key → UPDATE row with bt_id → fire-and-forget cache invalidation. * auto_topup/webhook/signature.rs — Stripe-Signature HMAC-SHA256 verifier with subtle::ConstantTimeEq, ±300s timestamp skew, multi-v1 rotation support. 11 unit tests. * auto_topup/webhook/mutex.rs — moka-backed PerCustomerMutex (5-min TTL). Optimization-only; correctness rests on the Postgres UNIQUE constraint + Stripe Idempotency-Keys (plan §11). * auto_topup/webhook/sca.rs — 32-byte URL-safe recovery token generator for the SCA `authentication_required` handoff (Phase 7 consumer). * auto_topup/db.rs — disable_after_failures, set_pending_action, clear_pending_action, try_insert_credit (ON CONFLICT DO NOTHING), mark_credit_completed. * auto_topup/webhook/handler.rs — the POST /stripe/webhook route. Raw Data handler (NOT Json — HMAC must verify exact bytes), bounded to 1 MiB. Filters event.type=customer.updated AND previous_attributes.balance present. Walks recent PIs with metadata filter client-side, derives consecutive failure count, enforces monthly cap. Handles paymentIntents.create succeeded / declined / authentication_required / timeout paths distinctly. SCA path saves pending_action_pi_id + recovery_token. Returns 5xx on transient errors so Stripe retries; 200 only after credit commits. Tests (9 integration tests, real Stripe test mode + local Postgres, silent-skip when STRIPE_SECRET_KEY / DATABASE_URL absent): - rejects_tampered_signature → 401 - rejects_stale_timestamp → 401 (codex gap #4) - ignores_non_customer_updated_event → 200 (cheap reject) - ignores_event_without_balance_change → 200 (codex gap #3) - short_circuits_when_config_disabled → no Stripe call - ignores_when_payload_balance_above_threshold → no Stripe call - happy_path_charges_and_credits → real off-session PI + credit row with non-null stripe_balance_transaction_id (codex gap #1 happy) - replay_of_same_event_is_safe → second delivery doesn't double-credit (codex gaps #2, #6) - cap_reached_skips_charge → second top-up refused after cap hit (codex gap #7) Plus 19 unit tests on signature, sca, and handler helpers (event-shape parsing, consecutive-failure derivation, month-spend sum, PI-id extraction). Test infrastructure: attach_test_card promoted from billing/auto_topup_config_tests.rs to billing/setup_intent_tests.rs so the webhook tests can share it. Cargo: subtle, thiserror direct deps added; moka "sync" feature enabled. Regression: 84 lit-payments tests + 18 lit-billing-auth + 15 lit-billing-core + 237 lit-api-server = 354 total, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): phase 5 P1 — config re-read under mutex, stable PI idempotency key, 5xx as Transient * feat(auto-topup): phase 6 — reconciler tokio task Tokio task spawned at startup, runs every RECONCILER_INTERVAL_SECS. Three branches per PI: row+bt_id skip; row+null bt_id retry with the credit:{pi.id} idempotency key; no row → full credit dance. Tests: 3 integration tests (orphan PI, partial credit, completed-row skip) against real Stripe + Postgres. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): phase 6 P1 — reconciler unions enabled+partial customers; age-gate partial retries * feat(auto-topup): phase 7 — SCA resume endpoints (codex P2 #3 fix folded in) GET /billing/auto_topup_resume?token=... does a non-consuming lookup, calls Stripe, then atomically clears the token only after Stripe succeeds. POST /complete re-fetches PI from Stripe, runs the sync credit path, clears pending state. Tests: 6 integration tests including the codex P2 #3 regression (get_resume_preserves_token_on_stripe_failure). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): phase 7 P1 — /complete retries credit when prior row exists with NULL balance_tx_id * feat(auto-topup): phase 8 — dashboard auto-recharge UI + SCA recovery page Lands the user-facing surface for Phases 1-7. Matches the OpenAI Platform auto-recharge UX (per attached design spec) using the dashboard's existing Bootstrap-style component vocabulary. * lit-static/dapps/dashboard/auto_recharge.js — new module. Talks directly to lit-payments (not the legacy api-server client). Surfaces: - Status banner with four states: not-configured / enabled-healthy (full description with threshold + topup + cap) / SCA-pending / auto-disabled-after-3-failures. Banner renders on page load via GET /billing/auto_topup_config and re-renders after every Save. - "Auto recharge" modal: toggle, "When balance drops to" + "Restore balance to" inputs (recharge amount auto-computed as the difference), monthly cap toggle + amount input, card picker, consent checkbox. Validates server-side invariants client-side too (cap >= topup, topup >= $5) for friendlier errors. Save fires PUT /billing/auto_topup_config. - Save-card sub-flow: POST /billing/setup_intent, mounts Stripe Payment Element in setup mode, stripe.confirmSetup handles 3DS automatically for SCA cards (CPL-329 MIT prior-auth). Returns the new pm_xxx into the parent modal. * lit-static/dapps/dashboard/recover_topup.html + recover_topup.js — standalone SCA recovery page reached from the tokenized email link (added by the Phase 5 webhook handler when an off-session PI returns authentication_required). GET /billing/auto_topup_resume returns the PI's client_secret without consuming the token; stripe.confirmPayment renders the bank's 3DS challenge in the Stripe iframe; on succeeded, POST /billing/auto_topup_resume/complete applies the sync credit and clears the pending state. The recovery_token is invalidated atomically by lit-payments after the GET's Stripe call succeeds — codex P2 #3's preserved-on-failure semantics in action. * lit-static/dapps/dashboard/index.html — adds the auto-recharge banner host inside the Overview section, the "Auto recharge" topbar button (visible to authed users), and the two new modals (config + save-card). * lit-static/dapps/dashboard/auth.js — adds getLitPaymentsBaseUrl() alongside getBaseUrl(). Dev: http://localhost:8001 (lit-payments now moves to 8001 to leave 8000 for lit-api-server, matching the dashboard's existing baseUrl assumption). Production: build-time __LIT_PAYMENTS_BASE_URL__ substitution, mirroring __LIT_API_BASE_URL__. * lit-static/dapps/dashboard/billing.js — exports getWalletAuthHeader() so the new auto-recharge module reuses the existing EIP-712 wallet-sig builder. Zero behavioral change. * lit-static/dapps/dashboard/styles.css — appends auto-recharge banner + modal styles. Uses the existing --primary / --success / --danger tokens; matches the .modal-* / .btn-* vocabulary already in the dashboard. Custom toggle switch in the modal header. * lit-payments/Cargo.toml + src/main.rs — adds rocket_cors so the dashboard (different origin / port) can call the new /billing/* endpoints. Same `AllowedOrigins::all()` shape lit-api-server uses. * lit-billing-auth/src/guard.rs — DEV-ONLY bypass: when LIT_DEV_WALLET_BYPASS=1 is set and the request carries `X-Dev-Wallet: 0x...`, the guard accepts it as a synthetic `BillingAuth::WalletSigned`. Used by the QA harness to drive the dashboard end-to-end without lit-api-server (which owns the on-chain resolver) running. Logs a clear `tracing::warn!` so the bypass is visible. The env var is read at request time, never cached. Browser QA (verified against real Stripe test mode + real local Postgres via gstack browse + 8001/8002 dev ports): * Banner empty state: "Set up" CTA. * Modal opens, pre-populated from DB row (real Stripe customer + attached pm_card_visa). * Save → PUT /billing/auto_topup_config → row updated in DB → banner re-renders to "Modify" with full description. * Banner SCA-pending state: red left border, "Action required", "Manage" CTA. (Seeded by UPDATE auto_topup_config SET disabled_reason='requires_action', pending_action_pi_id='pi_test', recovery_token='qa-token'). * Banner auto-disabled-after-failures state: "Auto recharge paused". * Save-card modal mounts Stripe Payment Element (visible card form, Stripe Link option, country/ZIP). * Recovery page (/recover_topup.html?token=...) successfully calls Stripe and mounts the Payment Element bound to a real PI's client_secret. * Recovery token single-use verified live: reload after first call returns 404 "Recovery link is invalid or expired." DB row shows recovery_token = NULL, pending_action_pi_id intact. Lit-payments regression: 94 lib tests pass (was 92 before; +2 for the Phase 8 P1 #2 pending-action regression test from the codex-fixes commit). lit-billing-auth: 18 pass. lit-billing-core: 16. lit-api-server: 223 lib + 14 integration. Total backend: 365 tests, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): phase 8 P1 — tighten CORS to exact-match allowlist + dev port doc * fix(auto-topup): glitch PR review fixes + Phase 7 idempotency expiry + clippy Addresses glitch003's review on PR #448 (the 4 items in scope this round) plus two related correctness items and the clippy lints that were blocking CI on all 8 phase PRs. * **Feature-flag the dev wallet bypass** (`lit-billing-auth/src/guard.rs`). `LIT_DEV_WALLET_BYPASS` + `X-Dev-Wallet` is now gated behind `#[cfg(debug_assertions)]`. `cargo build --release` strips the code entirely; the env var becomes inert in prod builds. Removes the runtime-flag liability glitch and codex both flagged as security-audit bait. * **Wire SCA recovery email** (`auto_topup/webhook/handler.rs`). `handle_sca_required` now fetches the customer's email from Stripe and dispatches the tokenized recovery link via `Mailer` (Resend). Fire-and-forget — a Resend outage logs loudly but doesn't fail the webhook (Stripe would otherwise retry and stage a second pending PI). Without this the 3DS challenge would sit forever and auto top-up would be permanently paused, which is what the engineer was worried about for EU cards. * **DB-driven reconciler partial repair** (`auto_topup/reconciler.rs`, `auto_topup/db.rs::list_partial_credit_rows`). New Pass A walks `auto_topup_credits WHERE stripe_balance_transaction_id IS NULL` directly — no Stripe time-window scan — and replays the balance_transactions write per row. Stripe is hit by PI id, which has no lookback restriction. Pass B (the legacy 7-day list scan) is kept only for the rarer "PI succeeded at Stripe but service crashed before DB INSERT" case. Pre-fix, any partial older than 7 days was stranded forever. * **Max-topup cap + topup>=threshold** (`billing/auto_topup_config.rs`, new migration `20260608000001_auto_topup_max_topup.sql`). `MAX_TOPUP_CENTS=20000` ($200). Off-session charging makes a hard ceiling per-charge meaningful: a compromised account or corrupt row is now bounded per-charge in addition to per-month. Requiring `topup >= threshold` ensures one charge actually brings the balance back above threshold — otherwise the next `customer.updated` would immediately fire another charge. DB CHECK constraint mirrors the handler validation. * **Phase 7 idempotency-key expiry guard** (`billing/sca_resume.rs`). Stripe drops a given `Idempotency-Key` after 24h, so reusing `credit:{pi_id}` after that window posts a new balance_tx instead of hitting dedup. Two layers: refuse the retry path with HTTP 409 `partial_credit_too_old` if `credited_at` is >=23h, and just before posting re-check the row in case a concurrent path already completed it. * **Delete `_authping` route** (`lit-payments/src/main.rs`). Phase 2 throwaway. Codex P3. * **Clippy + fmt fixes blocking CI**: - `db.rs` doc_lazy_continuation (rewrote a `+ reconciler,` continuation that clippy interpreted as a list item) - `webhook/handler.rs` identity_op (`1 * 1024 * 1024` → `1024 * 1024`) - `webhook/handler.rs` collapsible_if (merged the pre-mutex quick-exit `if let Some {...} { if ... }` into a single `if let && ...`) - `billing/auto_topup_config.rs` manual_range_contains (rewrote the `topup` validation as `!(MIN..=MAX).contains(&topup)`) - `billing-auth/guard.rs` needless_lifetimes (`extract_api_key` lost its explicit `'r`) - `webhook/handler.rs` too_many_arguments allow attribute on `process_event` (8 params, all distinct — splitting into a struct is the next refactor pass) CI was failing on all 8 phase PRs at the `cargo clippy --locked --all-features -- -D warnings` step on `lit-payments` and `lit-billing-auth`. After this commit: `cargo test --lib` clean (94 + 18), `cargo clippy --locked --all-features -- -D warnings` clean for both crates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): cargo fmt — lit-api-server/auth_resolver.rs * chore(deny): ignore RUSTSEC-2026-0173 (proc-macro-error2 unmaintained, transitive) * chore(deny): ignore RUSTSEC-2026-0174 (http-types 2.12 transitive in lit-actions) * fix(auto-topup): 11 codex P1 follow-ups across phases 2-8 Apply 11 P1 fixes flagged by an independent codex review of PR #448. All landed atomically in this commit so the PR's invariants hold. Tier A — security / plan-spec compliance: 1. setup_intent + auto_topup_config: API-key callers used to 501. Plan §5 says these endpoints sit "behind the shared auth module" which already verifies API keys via resolve_api_key. The handler now re-resolves the key through the same AuthResolver state (1h cache on api-server) to pull the wallet and continues the normal flow. Tests updated: the old "501 for ApiKey" assertions now verify the API-key path lands at the same status as the wallet-sig path (200 or 400 no_stripe_customer). 2. internal/guard.rs: constant-time compare must not short-circuit on length mismatch (§15 violation). Pad both inputs to the longer length with 0u8, ct_eq the equal-length buffers, then & with a length-equality Choice. Added 5 unit tests covering equal, content-diff, length-diff, both-empty, one-empty. Tier B — correctness gaps: 3. SCA recovery URL: use /recover_topup.html (explicit) instead of /recover_topup so the link works regardless of host-side extension stripping. 4. reconciler::repair_partial_credits: add 23h MAX_PARTIAL_RETRY_AGE cap mirroring the /complete endpoint's guard. Prevents Stripe idempotency-key TTL expiry from posting a duplicate balance_tx. 5. reconciler::repair_partial_credits: cross-verify Stripe PI `customer` and `amount` against the DB row before crediting. Defence against stale / tampered DB rows. 6. handle_sca_required: if the SCA recovery email fails to send, roll back the pending state in the DB so the next webhook tick re-stages SCA from scratch. Pre-fix the user was stranded — no link, and webhook short-circuited on pending_action_pi_id. 7. reconciler::run_once Pass B: union enabled customers with recently-active (last 7d) customers via new db::list_recently_active_customers. Catches the "off-session PI charged but service crashed pre-INSERT and auto-disabled flipped" case which was previously invisible. 8. db::upsert: clear pending_action_pi_id / recovery_token et al when the PUT changes payment_method_id, so a card switch mid-SCA doesn't leave a stale pending PI blocking new charges. 9. New migration 20260609000001_auto_topup_disabled_reason_check: CHECK constraint enumerating the four valid disabled_reason values ('manual', 'failures', 'card_invalid', 'requires_action'). 10. month_spend_cents: exclude requires_action PIs from the monthly cap calculation. They're abandoned SCA challenges, not spend; pre-fix a single 3DS drop-off could exhaust the budget without any successful charge. Tier C — fail-closed: 11. lit-api-server internal/config.rs: panic at startup in production builds if LIT_INTERNAL_SHARED_SECRET is missing or empty. Test / debug builds still return None so local cargo run / cargo test continue to work without the var set. Tests: - lit-payments lib: 95 passed - lit-billing-auth lib: 18 passed - lit-api-server internal:: 16 passed cargo fmt + clippy --lib clean on all three crates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(billing-auth): collapse HTTP-hop into shared crate (glitch follow-up) (#466) * refactor(auto-topup): move EIP-712 verifier into lit-billing-core Glitch's PR #448 review flagged that lit-payments hits lit-api-server over HTTP just to verify a wallet signature when the verifier is pure Rust with no on-chain dependency. Folding it into the shared crate lets both services call it in-process. What moved: - `lit-api-server/src/core/eip712.rs` (full implementation, ~25 tests) → `lit-billing-core/src/eip712.rs` - `verify_eip712_signature` now takes `chain_id: u64` as a parameter instead of reading lit-api-server's `GLOBAL_NODE_CONFIG`; each service supplies its own chain_id. - Replaces lit-api-server-specific `ApiStatus` returns with a new `Eip712Error` enum (BadRequest / Internal) so the shared crate has no knowledge of Rocket response types. Adapter on lit-api-server side: `core::eip712` is now a thin wrapper that reads chain_id from GLOBAL_NODE_CONFIG and translates Eip712Error back to ApiStatus, so existing callers in `account_management.rs` keep their signatures unchanged. Cites glitch's comment on `lit-api-server/src/internal/routes.rs:90` (/internal/verify_wallet_auth should not exist). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(auto-topup): add OnChainBillingResolver to lit-billing-core Lifts the lit-api-server `get_billing_wallet_address` lookup into the shared crate so lit-payments can resolve API key → billing wallet in process instead of via the `/internal/resolve_api_key` HTTP hop. To avoid making the shared crate depend on lit-api-server's 23k-LOC `sol!`-generated AccountConfig bindings, the resolver hand-rolls the ABI encoding for `getBillingWalletAddress(bytes32)`: - selector = keccak256("getBillingWalletAddress(bytes32)")[..4] - argument = 32-byte keccak256(key) (or pass-through for precomputed hash) - response = 32-byte left-padded address Each service constructs its own `OnChainBillingResolver` from its own env vars — `lit-api-server` from `GLOBAL_NODE_CONFIG`, `lit-payments` from `ALCHEMY_HTTPS_URL` + a new `LIT_BILLING_ACCOUNTS_CONTRACT` env. The resolver is dependency-light by design — no global state, no signer pool, just `reqwest` + bare JSON-RPC. Matches the established lit-payments LITKEY pattern (`chain.rs::HttpGatewayRpc`). Cites glitch's comment on `lit-api-server/src/internal/routes.rs:107` (/internal/resolve_api_key should not exist) and the comment on `lit-payments/src/auth_resolver.rs:99` (HttpAuthResolver should be replaced with a direct in-process call). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(auto-topup): fold lit-billing-auth into lit-billing-core Glitch's PR #448 review correctly flagged that maintaining `lit-billing-auth` as its own crate added ceremony with zero payoff: - Used by exactly two callers (lit-api-server, lit-payments) — the same two consumers of `lit-billing-core`. - The auth pieces and the Stripe pieces always ride together in any consumer; no third caller has ever needed just the auth half. What moved: - `lit-billing-auth/src/lib.rs::is_precomputed_hash_shape` → moved earlier to `lit-billing-core/src/on_chain.rs`, re-exported from `billing_auth::` for backward-compatible import paths. - `lit-billing-auth/src/guard.rs` (BillingAuth Rocket guard, tests) → `lit-billing-core/src/billing_auth/guard.rs` - `lit-billing-auth/src/resolver.rs` (AuthResolver, ResolvedIdentity, WalletAuthPayload, AuthError) → `lit-billing-core/src/billing_auth/resolver.rs` Cargo plumbing: - `lit-billing-core` gains an `openapi` feature flag (was on lit-billing-auth) plus rocket / base64_light / rocket_okapi / schemars deps. async-trait and thiserror added (used by resolver). - `lit-api-server` drops `lit-billing-auth` dep; enables `openapi` feature on `lit-billing-core`. - `lit-payments` drops `lit-billing-auth` dep. - The whole `lit-billing-auth/` directory is removed. All consumer source imports rewritten from `lit_billing_auth::*` to `lit_billing_core::billing_auth::*`. All tests passing on lit-billing-core (61 tests including the 13 guard tests previously in lit-billing-auth). Cites glitch's comment on `lit-billing-auth/src/lib.rs:1`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(auto-topup): replace HttpAuthResolver with in-process LocalAuthResolver The final piece of glitch's PR #448 architectural concern. With both the EIP-712 verifier and the on-chain billing-wallet lookup now in the shared `lit-billing-core` crate, lit-payments no longer needs an HTTP hop to lit-api-server for either operation. Changes: - `lit-payments/src/auth_resolver.rs` rewritten: `HttpAuthResolver` → `LocalAuthResolver`. Same `AuthResolver` impl shape as lit-api-server's local resolver — same primitives (`eip712::verify_eip712_signature` + `on_chain::OnChainBillingResolver`), same error classification (`BadRequest` → `BadCredentials`/401, `Internal`/`Transient` → 503), so behaviour is identical across both services. - `lit-payments/src/config.rs` gains three required env vars: - `LIT_ACCOUNTS_RPC_URL` — RPC endpoint for the AccountConfig chain - `LIT_ACCOUNTS_CHAIN_ID` — chain id for EIP-712 signature pinning - `LIT_ACCOUNTS_CONTRACT_ADDRESS` — deployed AccountConfig address (Same values lit-api-server reads from NodeConfig.toml; lit-payments has no equivalent file-based config so they come via env.) - `lit-payments/src/main.rs` constructs `LocalAuthResolver` from the parsed config. Removed `HttpAuthResolver::new` call. - `LIT_API_SERVER_BASE_URL` is intentionally kept — still used by the post-credit cache-invalidation callback to `/internal/invalidate_balance_cache`. - Test fixtures updated to include the three new Config fields. - README updated with the new env var block and a note that the old `LIT_API_SERVER_BASE_URL` is now scoped to cache invalidation only. All 95 lit-payments lib tests passing. Cites glitch's comment on `lit-payments/src/auth_resolver.rs:99` (HttpAuthResolver should be a direct in-process call). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(billing-auth): drop /internal HTTP auth endpoints (glitch PR review) Addresses glitch003's architectural feedback on PR #448 by completing the in-process refactor: lit-payments no longer reaches into lit-api-server to verify wallet signatures or resolve API keys. Removed: - POST /internal/verify_wallet_auth — replaced by direct call into lit-billing-core::eip712 verifier (pure signature recovery, no TEE-bound state — no reason for an HTTP hop). - POST /internal/resolve_api_key — replaced by direct call into lit-billing-core on-chain resolver (just reads a public contract mapping). - Associated request/response DTOs, MockResolver, and 7 route tests. Preserved: - POST /internal/invalidate_balance_cache — genuinely cross-service (lit-payments needs to nudge lit-api-server's in-memory Stripe balance cache after a sync credit). - The X-Internal-Secret guard, still used by the cache-invalidation call. Earlier commits in this branch (b9e164a7..HEAD) already: - Moved the EIP-712 verifier into lit-billing-core::eip712 - Moved the on-chain resolver into lit-billing-core::on_chain - Folded lit-billing-auth crate into lit-billing-core::billing_auth - Replaced HttpAuthResolver in lit-payments with LocalAuthResolver Net result: one less HTTP hop per authenticated lit-payments request, one less shared-secret surface for auth, one less crate. Tests: lit-billing-core 61 / lit-api-server 198 / lit-payments 95 all passing. cargo fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(billing-auth): on-chain selector — uint256 not bytes32 (codex P1) The Solidity contract declares `getBillingWalletAddress(uint256)` with selector 0x7249a9b6 (see lit-api-server's alloy-generated binding at accounts/contracts/account_config_contract.rs:1676 / :11815). The refactor introduced in PR #466 was computing the selector from keccak256("getBillingWalletAddress(bytes32)"), which produces a different 4-byte hash. The EVM dispatcher would not match it and every API-key resolve call from lit-payments would revert → collapsing to "no wallet address" / transient errors → API-key auth totally broken on lit-payments after this PR merges. Wire encoding of the argument is identical between uint256 and bytes32 (both 32 bytes big-endian), so only the selector string needed to change. No calldata layout change. Caught by codex post-refactor review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): UI polish + brand/last4 + optional cap + SCA fixes UX polish from manual browser testing: * $1 step on threshold/restore/cap inputs (was $0.01) * Removed "~" tilde from monthly cap display in the on-banner * Cap-toggle row visually grays out (opacity 0.45) when unchecked + bigger 18×18px checkbox with indigo accent * Modal card picker shows "Visa •••• 4242" instead of leaking pm_xxxxx — new `GET /billing/payment_method?pm_id=…` endpoint fetches brand+last4 from Stripe; AutoTopupConfigRow carries them in the existing config GET response. Frontend looks them up right after confirmCardSetup so the new card shows brand/last4 immediately. Validation relaxations (matching OpenAI/Anthropic UX): * Drop the `topup_amount >= threshold` check — restored as a hard constraint by an earlier codex round but it rejects perfectly reasonable configs like "drops below $20, restore to $30" (top-up $10). Migration `20260610000001` drops the DB CHECK clause too. * Make `monthly_cap_cents` truly optional. Handler validation + webhook step 8 cap check both treat None as "no monthly ceiling" (per-charge MAX_TOPUP_CENTS still bounds individual charges). Migration `20260610000002` adjusts the DB CHECK to allow NULL cap. Frontend sends null when the toggle is off. SCA recovery fixes: * `recover_topup.js` now uses `stripe.handleNextAction({clientSecret})` instead of mounting a fresh Payment Element + `confirmPayment`. The PI already has a card attached — we just need to trigger the bank's 3DS challenge, not collect new card details. Codex flagged this in an earlier per-phase review. * Stale-pending escape hatch in the webhook handler: if `pending_action_pi_id` is set AND `recovery_token_expires_at` is in the past, presume the user never completed 3DS (lost email, bank timeout, etc.) and clear pending state so the next customer.updated can fire a fresh SCA cycle. Without this, an expired recovery handoff permanently froze auto top-up with no operator-free recovery path. New `db::clear_pending_action_force` helper (unconditional clear, used only by the webhook escape hatch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): SCA email — show amount, card, merchant + stale-pending escape hatch Two follow-ups from manual browser QA: 1. SCA recovery email body was anonymous (no amount, no card, no "from"). Reworked to show: - Subject: "Action required: confirm your $X Lit Protocol auto top-up" - Body: amount, "Card: Visa •••• 4242", styled call-to-action button - New `fetch_pi_details(stripe, pi_id)` helper expands `payment_method` on the PI retrieve so we get brand/last4 in one Stripe call - New `prettify_brand()` matches the dashboard's `prettyBrand` JS helper for consistent display ("visa" → "Visa") 2. Stale-pending escape hatch in the webhook handler. If `pending_action_pi_id` is set AND `recovery_token_expires_at` is in the past, presume the user never completed 3DS (lost email, bank timeout, etc.) and clear pending state via the new `db::clear_pending_action_force` helper, then continue processing the current event to fire a fresh SCA cycle. Without this, an expired recovery handoff permanently froze auto top-up — no operator-free recovery path. Stripe research confirms: `requires_action` PIs do NOT auto-cancel; need to either prompt user on-session or cancel + create a new PI. We do the latter via "ignore the old one + create new" — Layer 2 (proactively cancel the old PI) is deferred. CI checks all green locally before push: - cargo fmt --all -- --check: clean (4 crates) - cargo clippy --locked --all-features -- -D warnings: clean (4 crates) - cargo test --lib: lit-payments 95, lit-billing-core 61, lit-api-server 198 — all passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): stale-pending escape hatch also checks Stripe PI status The previous escape hatch only triggered when `recovery_token_expires_at` was in the past (>24h since SCA was staged). That left a real prod-reachable bug: any time Stripe moves the PI out of `requires_action` *without* going through our `/complete` endpoint (bank rejects 3DS, user cancels the popup, card expires between PI create and email click, Stripe internal timeout, admin cancel via dashboard, etc.), our DB still thinks the PI is pending but `handleNextAction` errors with "PI not in requires_action." Customer was then permanently frozen with no operator-free recovery. Fix: when `pending_action_pi_id` is set, GET the PI from Stripe and treat any non-`requires_action` status as a signal to clear our pending state and let the current webhook fire a fresh SCA cycle. Costs one extra Stripe call per webhook for customers that actually have pending state — bounded and only on the slow path we'd otherwise short-circuit anyway. If the Stripe call itself fails, leave pending as-is and try again next tick — don't escalate (webhook should still 200). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): SCA recovery — use confirmCardPayment, not handleNextAction Empirical: when Stripe gets an off-session `paymentIntents.create` for a card that requires 3DS, it can't authenticate off-session, so it returns the error AND moves the PI directly to `requires_payment_method` (with `last_payment_error.code = authentication_required`) — NOT `requires_action`. The PI never sits in `requires_action` waiting for handleNextAction. handleNextAction therefore errors with "PaymentIntent supplied is not in the requires_action state" the moment the user clicks Confirm. Correct primitive: `stripe.confirmCardPayment(clientSecret)`. It re-confirms the existing PI with its already-saved payment method on-session, runs Stripe.js's 3DS challenge UI, and moves the PI through requires_action → succeeded. The saved card stays attached; no new card entry. Note: we still don't mount a Payment Element — the PI already has the card on it, and re-confirming just needs the client_secret. The old "mount Payment Element + confirmPayment" version was the one that broke things by submitting empty card data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): SCA recovery — pass saved pm_id to confirmCardPayment When an off-session paymentIntents.create returns authentication_required for an SCA card, Stripe DETACHES the payment_method from the PI as it moves it to `requires_payment_method`. Subsequent on-session confirmCardPayment(clientSecret) alone errors with "A payment method of type card was expected to be present, but this PaymentIntent does not have a payment method and none was provided." Fix: pass the saved pm_id explicitly via `confirmCardPayment(clientSecret, { payment_method: pm_xxx })`. The backend's GET /billing/auto_topup_resume response now carries the customer's `payment_method_id` from our config row alongside the existing client_secret + publishable_key. CI checks all clean locally before push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auto-topup): SCA escape hatch only fires on terminal PI or 24h token expiry Previous version cleared pending state on ANY non-`requires_action` PI status. But `requires_payment_method + last_payment_error = authentication_required` is the LIVE state the recovery email + the `/recover_topup.html` page are built to handle (off-session SCA failure detaches the payment_method, PI lands there). Clearing it on every subsequent `customer.updated` invalidated the in-flight email and re-sent a duplicate one each time the user ran a Lit Action — verified by manual QA where two balance adjustments produced two emails before completion. Narrowed: only clear when the PI is `canceled` / `succeeded` (terminal) OR the recovery token is past its 24h expiry. Everything else (including the off-session SCA failure state) is treated as live and left alone. Worst-case: a truly-abandoned recovery sits up to 24h before auto-clearing. No fix for the "user never returns" case beyond the 24h bound — acceptable. CI checks clean locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d182753 commit d0ec58b

64 files changed

Lines changed: 14639 additions & 771 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

lit-api-server/Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lit-api-server/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ apalis = { version = "0.6.0", features = ["tracing"] }
2020
apalis-sql = { version = "0.6.0", features = ["sqlite", "tokio-comp"] }
2121

2222
anyhow = "1.0"
23+
async-trait = "0.1"
2324
base64_light = "0.1"
2425
dstack-sdk = "0.1.2"
2526
derive_builder = "0.12.0"
@@ -43,7 +44,7 @@ sha3 = "0.10"
4344
ipfs-hasher = "0.13.0"
4445
elliptic-curve = "0.13.8"
4546
k256 = { version = "0.13.4", features = ["ecdsa"] }
46-
lit-billing-core = { path = "../lit-billing-core" }
47+
lit-billing-core = { path = "../lit-billing-core", features = ["openapi"] }
4748
lit-core = { path = "../lit-core/lit-core" }
4849
lit-core-derive = { path = "../lit-core/lit-core-derive" }
4950
lit-observability = { path = "../lit-core/lit-observability", features = ["channels"] }
@@ -76,6 +77,7 @@ rand = { version = "0.8" }
7677
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
7778
strum = "0.28.0"
7879
strum_macros = "0.28.0"
80+
subtle = "2.6"
7981
url = "2"
8082

8183
rocket_okapi = { version = "=0.9.0", features = ["swagger"] }
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
//! Local [`AuthResolver`] implementation — runs auth verification in-process.
2+
//!
3+
//! `lit-api-server` already owns the on-chain `allApiKeyHashesToMaster`
4+
//! resolver and the in-process EIP-712 verifier. This adapter wires those
5+
//! into the shared `lit-billing-core::billing_auth` trait so the same Rocket
6+
//! guard can authenticate handlers on this service without an HTTP hop.
7+
//!
8+
//! Post-glitch-refactor: `lit-payments` constructs an identical in-process
9+
//! resolver using the shared crate's primitives — see
10+
//! `lit-payments/src/auth_resolver.rs`. There is no HTTP fallback any more;
11+
//! the `/internal/verify_wallet_auth` + `/internal/resolve_api_key`
12+
//! endpoints have been deleted.
13+
14+
use alloy::primitives::keccak256;
15+
use async_trait::async_trait;
16+
use lit_billing_core::billing_auth::{
17+
AuthError, AuthResolver, ResolvedIdentity, WalletAuthPayload,
18+
};
19+
20+
use crate::core::eip712::{PRIMARY_TYPE_BILLING_AUTH, verify_eip712_signature};
21+
22+
pub struct LocalAuthResolver;
23+
24+
impl LocalAuthResolver {
25+
pub fn new() -> Self {
26+
Self
27+
}
28+
}
29+
30+
impl Default for LocalAuthResolver {
31+
fn default() -> Self {
32+
Self::new()
33+
}
34+
}
35+
36+
#[async_trait]
37+
impl AuthResolver for LocalAuthResolver {
38+
async fn verify_wallet_auth(
39+
&self,
40+
payload: &WalletAuthPayload,
41+
) -> Result<ResolvedIdentity, AuthError> {
42+
let wallet = verify_eip712_signature(
43+
&payload.typed_data,
44+
&payload.signature,
45+
PRIMARY_TYPE_BILLING_AUTH,
46+
)
47+
.map_err(|e| {
48+
// ApiStatus has 4xx and 5xx variants; we collapse to BadCredentials
49+
// since every error path here is some flavour of "this payload did
50+
// not authenticate." Chain/RPC failures inside verify_eip712 would
51+
// be surfaced through the chain-id check, which is also a 4xx.
52+
AuthError::BadCredentials(format!("eip712 verification failed: {e:?}"))
53+
})?;
54+
55+
let wallet_address_hex = format!("0x{:x}", wallet);
56+
let hash = keccak256(wallet.as_slice());
57+
let api_key_hash_hex = format!("0x{}", hex::encode(hash));
58+
Ok(ResolvedIdentity {
59+
wallet_address_hex,
60+
api_key_hash_hex,
61+
})
62+
}
63+
64+
async fn resolve_api_key(&self, api_key: &str) -> Result<ResolvedIdentity, AuthError> {
65+
// `get_billing_wallet_address` already accepts either a raw API key or
66+
// a precomputed 0x-prefixed hash. The Rocket guard rejects the latter
67+
// for the API-key header (CPL-285 hardening), so callers here always
68+
// pass a raw key, but the function is shared with WalletSigned
69+
// downstream so we keep the broad acceptance.
70+
//
71+
// Codex P2 (Phase 2) fix: distinguish "key has no wallet on chain"
72+
// (BadCredentials → 401) from "RPC / contract call failed"
73+
// (Transient → 503). Pre-fix this method collapsed every error to
74+
// Transient, so bad API keys produced 503s and made lit-payments
75+
// retry-loop for permanent credential failures.
76+
let wallet_address_hex = match crate::accounts::get_billing_wallet_address(api_key).await {
77+
Ok(v) => v,
78+
Err(e) => {
79+
// `accounts::get_billing_wallet_address` bails with "account
80+
// has no wallet address" when the on-chain mapping returns
81+
// Address::ZERO for this key hash — i.e. the key isn't
82+
// registered. Any other anyhow error is transport / RPC /
83+
// contract decoding noise → transient.
84+
let msg = format!("{e}");
85+
if msg.contains("no wallet address") || msg.contains("Address::ZERO") {
86+
return Err(AuthError::BadCredentials(format!(
87+
"api key not found on chain: {msg}"
88+
)));
89+
}
90+
return Err(AuthError::Transient(format!(
91+
"on-chain resolve failed: {msg}"
92+
)));
93+
}
94+
};
95+
96+
// Derive the api_key_hash_hex from the wallet address — same value
97+
// ChainSecured callers carry around so downstream caching keys match.
98+
let bytes = hex::decode(wallet_address_hex.trim_start_matches("0x"))
99+
.map_err(|e| AuthError::BadCredentials(format!("invalid wallet hex: {e}")))?;
100+
let hash = keccak256(&bytes);
101+
let api_key_hash_hex = format!("0x{}", hex::encode(hash));
102+
103+
Ok(ResolvedIdentity {
104+
wallet_address_hex,
105+
api_key_hash_hex,
106+
})
107+
}
108+
}

lit-api-server/src/core/v1/endpoints/billing.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
use std::sync::Arc;
22

3-
use crate::core::v1::guards::billing_auth::BillingAuth;
43
use crate::core::v1::helpers::api_status::{ApiResult, ApiStatus, ErrMessage};
54
use crate::core::v1::helpers::open_api_response::OpenApiResponse;
65
use crate::core::v1::models::request::{ConfirmPaymentRequest, CreatePaymentIntentRequest};
76
use crate::core::v1::models::response::{
87
AccountOpResponse, BillingBalanceResponse, CreatePaymentIntentResponse, StripeConfigResponse,
98
};
109
use crate::stripe::{self, StripeState};
10+
use lit_billing_core::billing_auth::BillingAuth;
1111
use rocket::State;
1212
use rocket::serde::json::Json;
1313
use rocket::{get, post};

0 commit comments

Comments
 (0)