From 71eb95c3de13a301402407681f73dbca7729470a Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Thu, 4 Jun 2026 18:02:14 -0700 Subject: [PATCH 01/10] feat(lit-payments): spending-rules storage + endpoints (lambda-parity PR 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the Lambda-parity work (plans/chipotle-lambda-parity.md): the durable home for per-key blast-radius controls the gateway will enforce. - Migration: spending_rules (rolling spend cap+window, rate rps+burst, concurrency, origin allowlist, enabled) and spending_usage (rolling spend counter with in-SQL window reset). - New `spending` module: typed rows + validated upsert request, sqlx db layer (upsert/get/list/delete rules, atomic record_charge with window reset), ServiceAuth bearer guard for gateway calls, and routes. - Operator-authed CRUD under /api/spending-rules (admin UI) and ServiceAuth-authed /internal endpoints (gateway: fetch rules for its cache, record spend off the response path). - INTERNAL_SERVICE_TOKEN config; /internal endpoints disabled (503) when unset. No production behavior change — nothing reads these tables yet. 9 unit tests cover request validation, hash canonicalization, and the token comparison. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../20260604000001_spending_rules.sql | 59 ++++++ lit-payments/src/config.rs | 6 + lit-payments/src/lib.rs | 1 + lit-payments/src/main.rs | 7 + lit-payments/src/spending/db.rs | 120 +++++++++++ lit-payments/src/spending/mod.rs | 59 ++++++ lit-payments/src/spending/routes.rs | 147 ++++++++++++++ lit-payments/src/spending/service_auth.rs | 78 ++++++++ lit-payments/src/spending/types.rs | 186 ++++++++++++++++++ 9 files changed, 663 insertions(+) create mode 100644 lit-payments/migrations/20260604000001_spending_rules.sql create mode 100644 lit-payments/src/spending/db.rs create mode 100644 lit-payments/src/spending/mod.rs create mode 100644 lit-payments/src/spending/routes.rs create mode 100644 lit-payments/src/spending/service_auth.rs create mode 100644 lit-payments/src/spending/types.rs diff --git a/lit-payments/migrations/20260604000001_spending_rules.sql b/lit-payments/migrations/20260604000001_spending_rules.sql new file mode 100644 index 00000000..5e4b0186 --- /dev/null +++ b/lit-payments/migrations/20260604000001_spending_rules.sql @@ -0,0 +1,59 @@ +-- Per-API-key spending rules + rolling usage for Lambda-parity blast-radius +-- controls on frontend-callable usage keys. See plans/chipotle-lambda-parity.md. +-- +-- The gateway (lit-api-server) reads `spending_rules` (cached, SWR) to enforce a +-- rolling spend cap, rate/concurrency limits, and an origin allowlist on keys +-- whose on-chain `hasSpendingRules` flag is set, and increments `spending_usage` +-- off the response path via the internal charge endpoint. Keys with no row here +-- are unaffected — the gateway never reaches this table unless the flag is set. +-- +-- `api_key_hash` is the keccak256 of the API key as a 0x-prefixed 32-byte hex +-- string (the same on-chain account identity used elsewhere), stored lowercase. + +CREATE TABLE spending_rules ( + api_key_hash TEXT PRIMARY KEY, + -- Billing/account wallet this key belongs to. Audit + grouping only. + account_wallet_address TEXT, + + -- Rolling spend cap (AWS-Budgets style). Both NULL = no spend cap. + spend_cap_cents BIGINT CHECK (spend_cap_cents IS NULL OR spend_cap_cents > 0), + spend_window_seconds BIGINT CHECK (spend_window_seconds IS NULL OR spend_window_seconds > 0), + + -- Per-key rate limit (token bucket). Both NULL = no rate limit. + rate_limit_rps INTEGER CHECK (rate_limit_rps IS NULL OR rate_limit_rps > 0), + rate_limit_burst INTEGER CHECK (rate_limit_burst IS NULL OR rate_limit_burst > 0), + + -- Max simultaneous in-flight executions. NULL = no concurrency cap. + max_concurrency INTEGER CHECK (max_concurrency IS NULL OR max_concurrency > 0), + + -- Browser origin allowlist (defense-in-depth). NULL/empty = no restriction. + allowed_origins TEXT[], + + -- Lets an operator disable a key's rules without deleting them. + enabled BOOLEAN NOT NULL DEFAULT TRUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- A spend cap needs both halves or neither. + CONSTRAINT spend_cap_complete CHECK ( + (spend_cap_cents IS NULL) = (spend_window_seconds IS NULL) + ), + -- A rate limit needs both halves or neither. + CONSTRAINT rate_limit_complete CHECK ( + (rate_limit_rps IS NULL) = (rate_limit_burst IS NULL) + ) +); + +CREATE INDEX spending_rules_wallet_idx ON spending_rules (account_wallet_address); + +-- Durable rolling spend counter, one row per key. Independent of spending_rules +-- (no FK) so the gateway's best-effort async charge never fails on a delete +-- race; orphan counters are harmless and cleared when rules are deleted. +CREATE TABLE spending_usage ( + api_key_hash TEXT PRIMARY KEY, + -- Anchor of the current rolling window; reset when the window elapses. + window_started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + spent_cents BIGINT NOT NULL DEFAULT 0 CHECK (spent_cents >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/lit-payments/src/config.rs b/lit-payments/src/config.rs index 84b1647b..14b1b887 100644 --- a/lit-payments/src/config.rs +++ b/lit-payments/src/config.rs @@ -39,6 +39,11 @@ pub struct Config { /// admin portal and rate poller run but LITKEY browser payments stay /// disabled. pub litkey_chain: Option, + /// Shared bearer token authenticating the gateway's internal calls to the + /// spending-rules endpoints (`/internal/*`). If unset, those endpoints are + /// disabled (503) — they are never left open. See + /// `crate::spending::service_auth`. + pub internal_service_token: Option, } impl Config { @@ -56,6 +61,7 @@ impl Config { max_daily_per_operator_cents: optional_i64("MAX_DAILY_PER_OPERATOR_CENTS", 10_000)?, litkey_discount_basis_points: parse_discount_basis_points()?, litkey_chain: parse_litkey_chain_config()?, + internal_service_token: optional_trimmed("INTERNAL_SERVICE_TOKEN"), }) } } diff --git a/lit-payments/src/lib.rs b/lit-payments/src/lib.rs index 3b3a9cba..7bd38baa 100644 --- a/lit-payments/src/lib.rs +++ b/lit-payments/src/lib.rs @@ -9,3 +9,4 @@ pub mod db; pub mod mail; pub mod portal; pub mod rate; +pub mod spending; diff --git a/lit-payments/src/main.rs b/lit-payments/src/main.rs index 76019d3c..02e8a126 100644 --- a/lit-payments/src/main.rs +++ b/lit-payments/src/main.rs @@ -5,6 +5,7 @@ use lit_billing_core::StripeClient; use lit_payments::auth::routes as auth_routes; use lit_payments::portal::routes as portal_routes; use lit_payments::rate; +use lit_payments::spending::routes as spending_routes; use lit_payments::{auth, chain, config, db, mail}; use rocket::fs::{FileServer, NamedFile}; use rocket::http::Status; @@ -55,6 +56,12 @@ async fn rocket() -> _ { chain::get_payment_config, chain::claim_payment, rate::override_rate, + spending_routes::put_rules, + spending_routes::get_rules, + spending_routes::list_rules, + spending_routes::delete_rules, + spending_routes::internal_get_rules, + spending_routes::internal_charge, ], ) .mount("/static", FileServer::from("static")) diff --git a/lit-payments/src/spending/db.rs b/lit-payments/src/spending/db.rs new file mode 100644 index 00000000..cc79aff5 --- /dev/null +++ b/lit-payments/src/spending/db.rs @@ -0,0 +1,120 @@ +//! Postgres queries for spending rules + rolling usage. +//! +//! Runtime `sqlx` (no compile-time DB), matching the rest of the service. + +use anyhow::Result; +use sqlx::PgPool; + +use super::types::{SpendingRules, SpendingUsage, UpsertRulesRequest}; + +/// Insert or replace the rules for a key, returning the stored row. +pub async fn upsert_rules( + pool: &PgPool, + api_key_hash: &str, + req: &UpsertRulesRequest, +) -> Result { + let row = sqlx::query_as::<_, SpendingRules>( + "INSERT INTO spending_rules ( + api_key_hash, account_wallet_address, spend_cap_cents, spend_window_seconds, + rate_limit_rps, rate_limit_burst, max_concurrency, allowed_origins, enabled, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) + ON CONFLICT (api_key_hash) DO UPDATE SET + account_wallet_address = EXCLUDED.account_wallet_address, + spend_cap_cents = EXCLUDED.spend_cap_cents, + spend_window_seconds = EXCLUDED.spend_window_seconds, + rate_limit_rps = EXCLUDED.rate_limit_rps, + rate_limit_burst = EXCLUDED.rate_limit_burst, + max_concurrency = EXCLUDED.max_concurrency, + allowed_origins = EXCLUDED.allowed_origins, + enabled = EXCLUDED.enabled, + updated_at = now() + RETURNING *", + ) + .bind(api_key_hash) + .bind(req.account_wallet_address.as_deref()) + .bind(req.spend_cap_cents) + .bind(req.spend_window_seconds) + .bind(req.rate_limit_rps) + .bind(req.rate_limit_burst) + .bind(req.max_concurrency) + .bind(req.allowed_origins.as_deref()) + .bind(req.enabled) + .fetch_one(pool) + .await?; + Ok(row) +} + +pub async fn get_rules(pool: &PgPool, api_key_hash: &str) -> Result> { + let row = sqlx::query_as::<_, SpendingRules>( + "SELECT * FROM spending_rules WHERE api_key_hash = $1", + ) + .bind(api_key_hash) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn get_usage(pool: &PgPool, api_key_hash: &str) -> Result> { + let row = sqlx::query_as::<_, SpendingUsage>( + "SELECT * FROM spending_usage WHERE api_key_hash = $1", + ) + .bind(api_key_hash) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn list_rules(pool: &PgPool, limit: i64) -> Result> { + let rows = sqlx::query_as::<_, SpendingRules>( + "SELECT * FROM spending_rules ORDER BY updated_at DESC LIMIT $1", + ) + .bind(limit) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Delete a key's rules and its usage counter. Returns whether a rules row existed. +pub async fn delete_rules(pool: &PgPool, api_key_hash: &str) -> Result { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM spending_usage WHERE api_key_hash = $1") + .bind(api_key_hash) + .execute(&mut *tx) + .await?; + let res = sqlx::query("DELETE FROM spending_rules WHERE api_key_hash = $1") + .bind(api_key_hash) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(res.rows_affected() > 0) +} + +/// Add `cents` to a key's rolling spend counter, resetting the window first if +/// it has elapsed. One atomic statement so concurrent charges can't race the +/// read-modify-write. Returns the post-charge counter. +pub async fn record_charge( + pool: &PgPool, + api_key_hash: &str, + cents: i64, + window_seconds: i64, +) -> Result { + let row = sqlx::query_as::<_, SpendingUsage>( + "INSERT INTO spending_usage AS u (api_key_hash, window_started_at, spent_cents, updated_at) + VALUES ($1, now(), $2, now()) + ON CONFLICT (api_key_hash) DO UPDATE SET + window_started_at = CASE + WHEN now() - u.window_started_at >= make_interval(secs => $3) + THEN now() ELSE u.window_started_at END, + spent_cents = CASE + WHEN now() - u.window_started_at >= make_interval(secs => $3) + THEN $2 ELSE u.spent_cents + $2 END, + updated_at = now() + RETURNING *", + ) + .bind(api_key_hash) + .bind(cents) + .bind(window_seconds as f64) + .fetch_one(pool) + .await?; + Ok(row) +} diff --git a/lit-payments/src/spending/mod.rs b/lit-payments/src/spending/mod.rs new file mode 100644 index 00000000..7bbd3f92 --- /dev/null +++ b/lit-payments/src/spending/mod.rs @@ -0,0 +1,59 @@ +//! Per-API-key spending rules + rolling usage. +//! +//! Storage and HTTP surface for the Lambda-parity blast-radius controls +//! (rolling spend cap, rate/concurrency limits, origin allowlist) that the +//! gateway enforces on frontend-callable usage keys. See +//! `plans/chipotle-lambda-parity.md`. +//! +//! - Operator (cookie-authed) routes under `/api/spending-rules` let the admin +//! UI read/set/clear a key's rules. +//! - Internal ([`ServiceAuth`]-authed) routes under `/internal` let the gateway +//! fetch rules for its cache and record spend off the response path. + +pub mod db; +pub mod routes; +pub mod service_auth; +pub mod types; + +pub use service_auth::ServiceAuth; + +/// Canonicalize an `api_key_hash` path/param: a 0x-prefixed 32-byte (64 hex +/// char) keccak256 hash, normalized to lowercase. The operator UI and the +/// gateway must agree on this exact representation. +pub fn canonical_key_hash(raw: &str) -> Result { + let s = raw.trim(); + let body = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s); + if body.len() != 64 || !body.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("api_key_hash must be a 0x-prefixed 32-byte hex string".into()); + } + Ok(format!("0x{}", body.to_ascii_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::canonical_key_hash; + + #[test] + fn normalizes_case_and_prefix() { + let h = "ABCD".repeat(16); // 64 hex chars + let with_prefix = format!("0x{h}"); + assert_eq!( + canonical_key_hash(&with_prefix).unwrap(), + format!("0x{}", h.to_ascii_lowercase()) + ); + // Accepts the unprefixed form too. + assert_eq!( + canonical_key_hash(&h).unwrap(), + format!("0x{}", h.to_ascii_lowercase()) + ); + } + + #[test] + fn rejects_wrong_length_or_non_hex() { + assert!(canonical_key_hash("0x1234").is_err()); + assert!(canonical_key_hash(&"zz".repeat(32)).is_err()); + } +} diff --git a/lit-payments/src/spending/routes.rs b/lit-payments/src/spending/routes.rs new file mode 100644 index 00000000..b56d1103 --- /dev/null +++ b/lit-payments/src/spending/routes.rs @@ -0,0 +1,147 @@ +//! Spending-rules HTTP routes. +//! +//! Operator-authed CRUD under `/api/spending-rules` (browser admin UI) and +//! `ServiceAuth`-authed endpoints under `/internal` (the gateway). + +use rocket::http::Status; +use rocket::serde::json::Json; +use rocket::{State, delete, get, post, put}; +use sqlx::PgPool; + +use super::db; +use super::types::{ + ChargeRequest, DeleteResponse, ErrorResponse, RulesListResponse, RulesWithUsage, SpendingRules, + SpendingUsage, UpsertRulesRequest, +}; +use super::{ServiceAuth, canonical_key_hash}; +use crate::auth::Operator; + +const DEFAULT_RULES_LIMIT: i64 = 100; +const MAX_RULES_LIMIT: i64 = 500; + +type ApiError = (Status, Json); +type ApiResult = Result, ApiError>; + +fn err(status: Status, message: impl Into) -> ApiError { + ( + status, + Json(ErrorResponse { + error: message.into(), + }), + ) +} + +fn server_err(e: impl std::fmt::Display + std::fmt::Debug) -> ApiError { + tracing::warn!(error = %e, error_debug = ?e, "spending route internal error"); + err(Status::InternalServerError, "internal error") +} + +fn parse_hash(raw: &str) -> Result { + canonical_key_hash(raw).map_err(|e| err(Status::BadRequest, e)) +} + +// ─── Operator (admin UI) ──────────────────────────────────────────────────── + +/// `PUT /api/spending-rules/` — create or replace a key's rules. +#[put("/api/spending-rules/", format = "json", data = "")] +pub async fn put_rules( + _operator: Operator, + api_key_hash: &str, + req: Json, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let req = req.into_inner(); + req.validate().map_err(|e| err(Status::BadRequest, e))?; + let rules = db::upsert_rules(pool, &hash, &req) + .await + .map_err(server_err)?; + Ok(Json(rules)) +} + +/// `GET /api/spending-rules/` — a key's rules + current usage. +#[get("/api/spending-rules/")] +pub async fn get_rules( + _operator: Operator, + api_key_hash: &str, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let rules = db::get_rules(pool, &hash) + .await + .map_err(server_err)? + .ok_or_else(|| err(Status::NotFound, "no rules for that key"))?; + let usage = db::get_usage(pool, &hash).await.map_err(server_err)?; + Ok(Json(RulesWithUsage { rules, usage })) +} + +/// `GET /api/spending-rules?limit=N` — recently-updated rules, newest first. +#[get("/api/spending-rules?")] +pub async fn list_rules( + _operator: Operator, + limit: Option, + pool: &State, +) -> ApiResult { + let limit = limit.unwrap_or(DEFAULT_RULES_LIMIT).clamp(1, MAX_RULES_LIMIT); + let rules = db::list_rules(pool, limit).await.map_err(server_err)?; + Ok(Json(RulesListResponse { rules })) +} + +/// `DELETE /api/spending-rules/` — clear a key's rules + usage counter. +#[delete("/api/spending-rules/")] +pub async fn delete_rules( + _operator: Operator, + api_key_hash: &str, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let deleted = db::delete_rules(pool, &hash).await.map_err(server_err)?; + Ok(Json(DeleteResponse { deleted })) +} + +// ─── Internal (gateway) ───────────────────────────────────────────────────── + +/// `GET /internal/spending-rules/` — rules + usage for the gateway's +/// cache. 404 when the key has no rules (the gateway caches that as "no rules"). +#[get("/internal/spending-rules/")] +pub async fn internal_get_rules( + _svc: ServiceAuth, + api_key_hash: &str, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let rules = db::get_rules(pool, &hash) + .await + .map_err(server_err)? + .ok_or_else(|| err(Status::NotFound, "no rules for that key"))?; + let usage = db::get_usage(pool, &hash).await.map_err(server_err)?; + Ok(Json(RulesWithUsage { rules, usage })) +} + +/// `POST /internal/spending-usage//charge` — add to the rolling spend +/// counter (resetting the window if elapsed). Called by the gateway off the +/// response path; best-effort. +#[post( + "/internal/spending-usage//charge", + format = "json", + data = "" +)] +pub async fn internal_charge( + _svc: ServiceAuth, + api_key_hash: &str, + req: Json, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let req = req.into_inner(); + if req.cents <= 0 { + return Err(err(Status::BadRequest, "cents must be positive")); + } + if req.window_seconds <= 0 { + return Err(err(Status::BadRequest, "window_seconds must be positive")); + } + let usage = db::record_charge(pool, &hash, req.cents, req.window_seconds) + .await + .map_err(server_err)?; + Ok(Json(usage)) +} diff --git a/lit-payments/src/spending/service_auth.rs b/lit-payments/src/spending/service_auth.rs new file mode 100644 index 00000000..50ef5b8a --- /dev/null +++ b/lit-payments/src/spending/service_auth.rs @@ -0,0 +1,78 @@ +//! `ServiceAuth` request guard — authenticates internal service-to-service +//! calls (the gateway reading rules / recording usage) via a shared bearer +//! token (`INTERNAL_SERVICE_TOKEN`). +//! +//! Distinct from the cookie-based [`Operator`](crate::auth::Operator) guard used +//! by the browser admin UI. If the token is not configured, internal endpoints +//! are disabled (503) rather than open. + +use rocket::http::Status; +use rocket::request::{FromRequest, Outcome, Request}; + +use crate::config::Config; + +pub struct ServiceAuth; + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ServiceAuth { + type Error = (); + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let Some(cfg) = req.rocket().state::() else { + tracing::error!("ServiceAuth guard: Config not in Rocket state"); + return Outcome::Error((Status::InternalServerError, ())); + }; + let Some(expected) = cfg.internal_service_token.as_deref() else { + // Not configured → internal endpoints are off, not open. + return Outcome::Error((Status::ServiceUnavailable, ())); + }; + match bearer(req) { + Some(provided) if constant_time_eq(provided.as_bytes(), expected.as_bytes()) => { + Outcome::Success(ServiceAuth) + } + _ => Outcome::Error((Status::Unauthorized, ())), + } + } +} + +/// Extract a `Authorization: Bearer ` value. +fn bearer(req: &Request<'_>) -> Option { + let v = req.headers().get_one("Authorization")?; + let mut parts = v.split_whitespace(); + match (parts.next(), parts.next()) { + (Some(scheme), Some(token)) + if scheme.eq_ignore_ascii_case("bearer") && !token.trim().is_empty() => + { + Some(token.trim().to_string()) + } + _ => None, + } +} + +/// Length-aware constant-time byte comparison (the length is allowed to leak). +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::constant_time_eq; + + #[test] + fn equal_tokens_match() { + assert!(constant_time_eq(b"s3cret-token", b"s3cret-token")); + } + + #[test] + fn different_tokens_or_lengths_fail() { + assert!(!constant_time_eq(b"s3cret-token", b"s3cret-tokeX")); + assert!(!constant_time_eq(b"short", b"longer-token")); + } +} diff --git a/lit-payments/src/spending/types.rs b/lit-payments/src/spending/types.rs new file mode 100644 index 00000000..be80ab95 --- /dev/null +++ b/lit-payments/src/spending/types.rs @@ -0,0 +1,186 @@ +//! Request/response + row shapes for per-key spending rules and rolling usage. +//! +//! See `plans/chipotle-lambda-parity.md`. The gateway reads these to enforce a +//! rolling spend cap, rate/concurrency limits, and an origin allowlist on +//! frontend-callable usage keys. + +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::OffsetDateTime; + +/// A row of `spending_rules` — the configured limits for one API key. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SpendingRules { + pub api_key_hash: String, + pub account_wallet_address: Option, + pub spend_cap_cents: Option, + pub spend_window_seconds: Option, + pub rate_limit_rps: Option, + pub rate_limit_burst: Option, + pub max_concurrency: Option, + pub allowed_origins: Option>, + pub enabled: bool, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +/// A row of `spending_usage` — the rolling spend counter for one API key. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SpendingUsage { + pub api_key_hash: String, + #[serde(with = "time::serde::rfc3339")] + pub window_started_at: OffsetDateTime, + pub spent_cents: i64, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +/// Body of `PUT /api/spending-rules/` (operator) — the editable fields. +/// Omitted fields default to "no limit". Paired fields (cap+window, rps+burst) +/// must be supplied together; validated before write. +#[derive(Debug, Default, Deserialize)] +pub struct UpsertRulesRequest { + #[serde(default)] + pub account_wallet_address: Option, + #[serde(default)] + pub spend_cap_cents: Option, + #[serde(default)] + pub spend_window_seconds: Option, + #[serde(default)] + pub rate_limit_rps: Option, + #[serde(default)] + pub rate_limit_burst: Option, + #[serde(default)] + pub max_concurrency: Option, + #[serde(default)] + pub allowed_origins: Option>, + /// Defaults to enabled when omitted. + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + +/// What the gateway fetches for its rules cache: the rules plus current usage. +#[derive(Debug, Serialize)] +pub struct RulesWithUsage { + pub rules: SpendingRules, + pub usage: Option, +} + +/// Body of the internal `POST /internal/spending-usage//charge`. The +/// gateway supplies the window length (it has the rules cached) so the counter +/// can self-reset without a second query. +#[derive(Debug, Deserialize)] +pub struct ChargeRequest { + pub cents: i64, + pub window_seconds: i64, +} + +#[derive(Debug, Serialize)] +pub struct RulesListResponse { + pub rules: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DeleteResponse { + pub deleted: bool, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +impl UpsertRulesRequest { + /// Reject incomplete pairs / non-positive values before they hit the DB + /// CHECK constraints, so callers get a clear 400 instead of a 500. + pub fn validate(&self) -> Result<(), String> { + if self.spend_cap_cents.is_some() != self.spend_window_seconds.is_some() { + return Err("spend_cap_cents and spend_window_seconds must be set together".into()); + } + if self.rate_limit_rps.is_some() != self.rate_limit_burst.is_some() { + return Err("rate_limit_rps and rate_limit_burst must be set together".into()); + } + for (name, v) in [ + ("spend_cap_cents", self.spend_cap_cents), + ("spend_window_seconds", self.spend_window_seconds), + ] { + if let Some(v) = v + && v <= 0 + { + return Err(format!("{name} must be positive")); + } + } + for (name, v) in [ + ("rate_limit_rps", self.rate_limit_rps), + ("rate_limit_burst", self.rate_limit_burst), + ("max_concurrency", self.max_concurrency), + ] { + if let Some(v) = v + && v <= 0 + { + return Err(format!("{name} must be positive")); + } + } + if let Some(origins) = &self.allowed_origins + && origins.iter().any(|o| o.trim().is_empty()) + { + return Err("allowed_origins must not contain empty entries".into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base() -> UpsertRulesRequest { + UpsertRulesRequest { + enabled: true, + ..Default::default() + } + } + + #[test] + fn empty_rules_are_valid() { + assert!(base().validate().is_ok()); + } + + #[test] + fn spend_cap_requires_both_halves() { + let mut r = base(); + r.spend_cap_cents = Some(1000); + assert!(r.validate().is_err()); + r.spend_window_seconds = Some(86_400); + assert!(r.validate().is_ok()); + } + + #[test] + fn rate_limit_requires_both_halves() { + let mut r = base(); + r.rate_limit_rps = Some(10); + assert!(r.validate().is_err()); + r.rate_limit_burst = Some(20); + assert!(r.validate().is_ok()); + } + + #[test] + fn rejects_non_positive() { + let mut r = base(); + r.max_concurrency = Some(0); + assert!(r.validate().is_err()); + } + + #[test] + fn rejects_empty_origin() { + let mut r = base(); + r.allowed_origins = Some(vec!["https://app.example.com".into(), " ".into()]); + assert!(r.validate().is_err()); + } +} From f185c7c4aca9810deda53a386943eb2e8b08050f Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 9 Jun 2026 17:56:59 -0700 Subject: [PATCH 02/10] feat(account-config): hasSpendingRules flag + multicall view (lambda-parity PR 3) The on-chain zero-latency gate for per-key spending rules. All additive and storage-safe: a new mapping appended to the END of root AccountConfigStorage (not a field on the inline-embedded UsageApiKey struct, which would shift the Account layout). - AppStorage: append `mapping(uint256 => bool) usageKeyHasSpendingRules`. - ViewsFacet: `getSpendingRulesFlag(apiKeyHash)` and `canExecuteActionWithSpendingRules(apiKeyHash, cidHash) -> (canExecute, hasSpendingRules)` so the gateway reads both in one RPC. - WritesFacet: `setSpendingRulesFlag(accountApiKeyHash, usageApiKeyHash, bool)` + event, same account-access control as setUsageApiKey. Source only; `forge build` passes. Regenerate the Rust bindings + diamond ABI with `make generate` on the canonical toolchain, then deploy via Safe diamond-cut on Base (both ops steps). The gateway reads the new view through a scoped sol! interface, so it does not depend on the regenerated giant binding. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AccountConfigFacets/AppStorage.sol | 7 +++++ .../AccountConfigFacets/ViewsFacet.sol | 23 ++++++++++++++++ .../AccountConfigFacets/WritesFacet.sol | 27 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/AppStorage.sol b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/AppStorage.sol index 3c0cf677..3bfaa72e 100644 --- a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/AppStorage.sol +++ b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/AppStorage.sol @@ -119,6 +119,13 @@ library AppStorage { EnumerableSet.StringSet nodeConfigurationKeys; mapping(string => string) nodeConfigurationValues; uint256 serverTriggerValue; + // Lambda-parity: per-usage-key flag gating off-hot-path spending-rule + // enforcement (rolling spend cap, rate/concurrency limits, origin + // allowlist) stored off-chain. False for every existing key, so the + // gateway does zero extra work unless it is explicitly set. Appended at + // the end of root storage so the existing layout is untouched. + // See plans/chipotle-lambda-parity.md. + mapping(uint256 => bool) usageKeyHasSpendingRules; } function getStorage() diff --git a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol index 0f04a81c..2fb54b62 100644 --- a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol +++ b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol @@ -405,6 +405,29 @@ contract ViewsFacet { return apiKeyCanExecuteForAnyGroup(apiKeyHash, groupIds); } + /// @notice Whether a usage API key has off-chain spending rules the gateway + /// must enforce. False for every key that never set it, so the + /// gateway's hot path stays free for keys without rules. + function getSpendingRulesFlag( + uint256 apiKeyHash + ) public view returns (bool) { + return AppStorage.getStorage().usageKeyHasSpendingRules[apiKeyHash]; + } + + /// @notice Combined hot-path check: returns (canExecute, hasSpendingRules) + /// in a single call so the gateway reads both in one RPC and pays no + /// extra round trip for the spending-rules gate. + /// See plans/chipotle-lambda-parity.md. + function canExecuteActionWithSpendingRules( + uint256 apiKeyHash, + uint256 cidHash + ) public view returns (bool canExecute, bool hasSpendingRules) { + canExecute = canExecuteAction(apiKeyHash, cidHash); + hasSpendingRules = AppStorage.getStorage().usageKeyHasSpendingRules[ + apiKeyHash + ]; + } + function canUseWalletInAction( uint256 apiKeyHash, uint256 cidHash, diff --git a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol index f4a2dfbb..5d616026 100644 --- a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol +++ b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol @@ -25,6 +25,11 @@ contract WritesFacet { uint256 indexed accountApiKeyHash, uint256 indexed usageApiKeyHash ); + event SpendingRulesFlagSet( + uint256 indexed accountApiKeyHash, + uint256 indexed usageApiKeyHash, + bool hasSpendingRules + ); event GroupAdded(uint256 indexed apiKeyHash, uint256 indexed groupId); event GroupUpdated( uint256 indexed accountApiKeyHash, @@ -321,6 +326,28 @@ contract WritesFacet { emit UsageApiKeySet(masterAccountApiKeyHash, usageApiKeyHash); } + /// @notice Set the off-chain spending-rules flag for a usage API key. + /// @dev When true, the gateway enforces this key's off-chain spending rules + /// (rolling spend cap, rate/concurrency limits, origin allowlist); when + /// false it skips all of that on the hot path. Same account-access + /// control as setUsageApiKey. The detailed rules live off-chain (see + /// lit-payments); this only flips the gate. See + /// plans/chipotle-lambda-parity.md. + function setSpendingRulesFlag( + uint256 accountApiKeyHash, + uint256 usageApiKeyHash, + bool hasSpendingRules + ) public { + SecurityLib.revertIfNoAccountAccess(accountApiKeyHash, msg.sender); + AppStorage.AccountConfigStorage storage s = AppStorage.getStorage(); + s.usageKeyHasSpendingRules[usageApiKeyHash] = hasSpendingRules; + emit SpendingRulesFlagSet( + accountApiKeyHash, + usageApiKeyHash, + hasSpendingRules + ); + } + function addGroup( uint256 apiKeyHash, string memory name, From f3fcc50b3af04de450eab403629a73cfae9a8bff Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 9 Jun 2026 18:11:08 -0700 Subject: [PATCH 03/10] feat(lit-api-server): enforce per-key spending rules on the hot path (lambda-parity PR 2) Gateway side of the Lambda-parity work. Reads the on-chain hasSpendingRules gate in the same multicall as the execute-permission check; keys without rules pay zero extra work. - accounts: can_execute_action_with_spending_rules -> (can_execute, has_spending_rules), cached in BlockchainCache (new execute_and_spending entry, same generation invalidation). Reads the new view via a scoped sol! interface (read_only_client_and_address helper) so it tracks the workspace alloy version instead of the regenerated giant binding. - core::spending_rules: flag-gated enforcer. On a flagged key it fetches rules (cached, TTL) from lit-payments /internal, then enforces a rolling spend cap (402), a per-node token-bucket rate limit (429), and a per-node concurrency cap (429). Spend is recorded off the response path (in-memory + fire-and- forget POST). Inert unless LIT_PAYMENTS_INTERNAL_URL + INTERNAL_SERVICE_TOKEN are set AND the key's on-chain flag is on. - Wired into core_features::lit_action + the /lit_action route + main state. - 429 (too_many_requests) ApiStatus helper. Origin allowlist (P2.1) deferred. SWR background refresh for the rules cache is a marked follow-up (currently TTL with inline cold-miss fetch). cargo check clean; 4 unit tests for the token bucket, window reset, and hash format. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/accounts/blockchain_cache.rs | 20 + lit-api-server/src/accounts/mod.rs | 61 +++ .../src/accounts/signable_contract.rs | 22 + lit-api-server/src/core/core_features.rs | 20 +- lit-api-server/src/core/mod.rs | 1 + lit-api-server/src/core/spending_rules.rs | 430 ++++++++++++++++++ .../src/core/v1/endpoints/actions.rs | 3 + .../src/core/v1/helpers/api_status.rs | 9 + lit-api-server/src/main.rs | 1 + 9 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 lit-api-server/src/core/spending_rules.rs diff --git a/lit-api-server/src/accounts/blockchain_cache.rs b/lit-api-server/src/accounts/blockchain_cache.rs index cecab304..4067f5bf 100644 --- a/lit-api-server/src/accounts/blockchain_cache.rs +++ b/lit-api-server/src/accounts/blockchain_cache.rs @@ -35,6 +35,8 @@ pub struct BlockchainCache { use_wallet: Cache, /// `can_execute_action_and_use_wallet` results. execute_and_wallet: Cache, + /// `can_execute_action_with_spending_rules` results: (canExecute, hasSpendingRules). + execute_and_spending: Cache, /// `get_wallet_derivation` results. wallet_derivation: Cache, /// Per-account generation counter keyed by the string representation of @@ -62,6 +64,11 @@ impl BlockchainCache { .time_to_idle(ttl) .time_to_live(ttl) .build(); + let execute_and_spending = Cache::builder() + .max_capacity(MAX_CAPACITY) + .time_to_idle(ttl) + .time_to_live(ttl) + .build(); let wallet_derivation = Cache::builder() .max_capacity(MAX_CAPACITY) .time_to_idle(ttl) @@ -71,6 +78,7 @@ impl BlockchainCache { execute_action, use_wallet, execute_and_wallet, + execute_and_spending, wallet_derivation, generations: RwLock::new(HashMap::new()), } @@ -112,6 +120,13 @@ impl BlockchainCache { format!("{h}:g{g}:ew:{cid_hash}:{wallet:#x}") } + /// Build a cache key for `can_execute_action_with_spending_rules`. + pub fn execute_and_spending_key(&self, api_key_hash: U256, cid_hash: U256) -> String { + let h = api_key_hash.to_string(); + let g = self.generation(&h); + format!("{h}:g{g}:es:{cid_hash}") + } + /// Build a cache key for `get_wallet_derivation`. pub fn wallet_derivation_key(&self, api_key_hash: U256, wallet: Address) -> String { let h = api_key_hash.to_string(); @@ -134,6 +149,11 @@ impl BlockchainCache { &self.execute_and_wallet } + /// Reference to the `can_execute_action_with_spending_rules` cache. + pub fn execute_and_spending_cache(&self) -> &Cache { + &self.execute_and_spending + } + /// Reference to the `get_wallet_derivation` cache. pub fn wallet_derivation_cache(&self) -> &Cache { &self.wallet_derivation diff --git a/lit-api-server/src/accounts/mod.rs b/lit-api-server/src/accounts/mod.rs index ee174070..5772802c 100644 --- a/lit-api-server/src/accounts/mod.rs +++ b/lit-api-server/src/accounts/mod.rs @@ -676,6 +676,67 @@ pub async fn can_execute_action(api_key: &str, cid_hash: U256) -> Result { Ok(can_execute) } +/// Scoped binding for the spending-rules view added in lambda-parity PR 3. +/// Defined here (not via the giant generated binding) so it tracks the workspace +/// alloy version directly; fold into the generated binding once it is +/// regenerated on the canonical toolchain. See `plans/chipotle-lambda-parity.md`. +mod spending_view { + alloy::sol! { + #[sol(rpc)] + contract SpendingView { + function canExecuteActionWithSpendingRules( + uint256 apiKeyHash, + uint256 cidHash + ) external view returns (bool canExecute, bool hasSpendingRules); + } + } +} + +async fn fetch_execute_and_spending( + account_api_key_hash: U256, + cid_hash_eth: U256, +) -> Result<(bool, bool)> { + let (client, address) = crate::accounts::signable_contract::read_only_client_and_address()?; + let contract = spending_view::SpendingView::new(address, client); + let result = contract + .canExecuteActionWithSpendingRules(account_api_key_hash, cid_hash_eth) + .call() + .await?; + Ok((result.canExecute, result.hasSpendingRules)) +} + +/// Combined hot-path check: `(can_execute, has_spending_rules)` in a single RPC. +/// +/// `has_spending_rules` is the zero-latency gate for per-key Lambda-parity +/// controls — false for every key that never set it, so the common path does no +/// extra work. See `plans/chipotle-lambda-parity.md`. +#[instrument( + name = "accounts::can_execute_action_with_spending_rules", + level = "debug", + skip_all, + err +)] +pub async fn can_execute_action_with_spending_rules( + api_key: &str, + cid_hash: U256, +) -> Result<(bool, bool)> { + let account_api_key_hash = api_key_hash(api_key); + let cid_hash_eth = cid_hash; + + if let Some(cache) = blockchain_cache::get() { + let key = cache.execute_and_spending_key(account_api_key_hash, cid_hash); + return cache + .execute_and_spending_cache() + .try_get_with(key, async move { + fetch_execute_and_spending(account_api_key_hash, cid_hash_eth).await + }) + .await + .map_err(|e: Arc| anyhow::anyhow!("{:#}", e)); + } + + fetch_execute_and_spending(account_api_key_hash, cid_hash_eth).await +} + #[instrument( name = "accounts::can_use_wallet_in_action", level = "debug", diff --git a/lit-api-server/src/accounts/signable_contract.rs b/lit-api-server/src/accounts/signable_contract.rs index baa88389..c9b89a95 100644 --- a/lit-api-server/src/accounts/signable_contract.rs +++ b/lit-api-server/src/accounts/signable_contract.rs @@ -93,6 +93,28 @@ pub async fn get_admin_api_signer() -> Result { signer_provider(wallet) } +/// Read-only provider + the AccountConfig address, for ad-hoc scoped `sol!` +/// interfaces that target functions not yet present in the regenerated giant +/// binding (e.g. the spending-rules view from lambda-parity PR 3). Tracks the +/// workspace alloy version directly; fold callers into the generated binding +/// once it is regenerated on the canonical toolchain. +pub(crate) fn read_only_client_and_address() -> Result<(SigningClient, Address)> { + let client = GLOBAL_READ_ONLY_CLIENT + .get() + .ok_or_else(|| { + anyhow::anyhow!( + "Read-only client not initialised — call init_chain_clients() at startup" + ) + })? + .clone(); + let node_config = GLOBAL_NODE_CONFIG + .get() + .ok_or_else(|| anyhow::anyhow!("Node configuration not found"))?; + let account_config_address = + Address::from_slice(&hex_to_bytes(&node_config.contract_address)?); + Ok((client, account_config_address)) +} + pub(crate) async fn get_read_only_account_config_contract() -> Result { let client = GLOBAL_READ_ONLY_CLIENT .get() diff --git a/lit-api-server/src/core/core_features.rs b/lit-api-server/src/core/core_features.rs index 35c840ba..5d747520 100644 --- a/lit-api-server/src/core/core_features.rs +++ b/lit-api-server/src/core/core_features.rs @@ -1,5 +1,6 @@ -use crate::accounts::can_execute_action; +use crate::accounts::can_execute_action_with_spending_rules; use crate::accounts::chain_config::{ChainConfig, ConfigKeys}; +use crate::core::spending_rules::SpendingRulesState; use crate::actions::client::ClientBuilder; use crate::actions::client::models::DenoExecutionEnv; use crate::actions::client::{ @@ -32,6 +33,7 @@ pub async fn lit_action( http_client: &reqwest::Client, chain_config: Arc, stripe_state: Option>, + spending: &SpendingRulesState, lit_action_request: Json, ) -> Result { let request_id = request_span.request_id.clone(); @@ -49,7 +51,10 @@ pub async fn lit_action( ) .await?; let cid_hash = ipfs_cid_to_u256(&derived_ipfs_id)?; - let can_execute = can_execute_action(api_key, cid_hash) + // Single multicall returns the execute permission AND the zero-latency + // spending-rules gate. has_spending_rules is false for almost every key, so + // the spending-rules path below is skipped entirely for them. + let (can_execute, has_spending_rules) = can_execute_action_with_spending_rules(api_key, cid_hash) .instrument(tracing::debug_span!("lit_action::can_execute_action")) .await?; if !can_execute { @@ -59,6 +64,11 @@ pub async fn lit_action( return Err(ApiStatus::forbidden(msg)); } + // Enforce per-key spending rules (rolling cap / rate / concurrency) before + // execution. Inert unless the key is flagged AND enforcement is configured. + // The returned admission holds any concurrency permit until end of scope. + let admission = spending.admit(api_key, has_spending_rules).await?; + // Cache after authorization so unauthorized requests cannot pollute the cache. ipfs_cache .insert(derived_ipfs_id.clone(), Arc::new(code_to_run.clone())) @@ -96,6 +106,7 @@ pub async fn lit_action( action_ipfs_id: Some(derived_ipfs_id), }; + let exec_start = std::time::Instant::now(); let result = match client .execute_js(execution_options) .instrument(tracing::debug_span!("lit_action::execute_js")) @@ -105,6 +116,11 @@ pub async fn lit_action( Err(e) => return Err(anyhow::anyhow!("Actions failed with : {:?}", e).into()), }; + // Record execution against the key's rolling spend counter (no-op unless the + // key has a spend cap). Off the response path — the local update is in-memory + // and the lit-payments write is fire-and-forget. + admission.record_seconds(exec_start.elapsed().as_secs_f64().ceil() as u64); + let response = match serde_json::from_str::(&result.response) { Ok(response) => response, Err(e) => { diff --git a/lit-api-server/src/core/mod.rs b/lit-api-server/src/core/mod.rs index 34426b39..7961fc9e 100644 --- a/lit-api-server/src/core/mod.rs +++ b/lit-api-server/src/core/mod.rs @@ -3,6 +3,7 @@ use crate::utils::{parse_with_hash::pkp_id_to_h160, u256_to_derviation_path}; pub mod account_management; pub mod core_features; pub mod eip712; +pub mod spending_rules; pub mod v1; pub async fn pkp_id_to_derviation_path(api_key: &str, pkp_id: &str) -> Result { diff --git a/lit-api-server/src/core/spending_rules.rs b/lit-api-server/src/core/spending_rules.rs new file mode 100644 index 00000000..b96cbe7c --- /dev/null +++ b/lit-api-server/src/core/spending_rules.rs @@ -0,0 +1,430 @@ +//! Gateway-side enforcement of per-key spending rules (Lambda parity). +//! +//! Only reached when a key's on-chain `hasSpendingRules` flag is set (see +//! `accounts::can_execute_action_with_spending_rules`), so keys without rules +//! pay zero added latency. For a flagged key, the rules + current rolling spend +//! are fetched (cached) from lit-payments' `/internal` endpoints and enforced +//! before execution: +//! +//! - **rolling spend cap** (402 when reached), +//! - **rate limit** — per-node token bucket (429), +//! - **concurrency cap** — per-node in-flight counter (429). +//! +//! Spend is recorded back to lit-payments off the response path. Counters are +//! in-process and per-node — acceptable because the durable spend cap is the +//! real backstop. See `plans/chipotle-lambda-parity.md`. +//! +//! Inert until configured: if `LIT_PAYMENTS_INTERNAL_URL` / +//! `INTERNAL_SERVICE_TOKEN` are unset, `admit` always allows and records nothing. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use moka::future::Cache; +use serde::Deserialize; + +use crate::core::v1::helpers::api_status::ApiStatus; + +/// SWR-ish freshness for the rules cache (kept short; only flagged keys pay it). +const RULES_CACHE_TTL: Duration = Duration::from_secs(30); +const RULES_CACHE_CAPACITY: u64 = 100_000; +/// Bound the hot-path cold-miss fetch so a slow lit-payments can't stall a call. +const FETCH_TIMEOUT: Duration = Duration::from_secs(2); +/// Per-second execution cost, mirrored from the Stripe charge rate so the +/// per-key counter tracks roughly what the account is billed. +const COST_PER_SECOND_CENTS: i64 = crate::stripe::COST_LIT_ACTION_PER_SECOND_CENTS; + +/// The rules the gateway enforces for one key (subset of the lit-payments row). +#[derive(Debug, Clone, Deserialize)] +pub struct RuleSet { + pub spend_cap_cents: Option, + pub spend_window_seconds: Option, + pub rate_limit_rps: Option, + pub rate_limit_burst: Option, + pub max_concurrency: Option, + pub enabled: bool, +} + +/// Shape of `GET /internal/spending-rules/` from lit-payments. +#[derive(Debug, Deserialize)] +struct RulesWithUsage { + rules: RuleSet, + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct UsageRow { + spent_cents: i64, +} + +/// Local rolling-spend counter, seeded from lit-payments and incremented on each +/// charge. Window resets locally when it elapses (fixed-window). +#[derive(Debug)] +struct Usage { + window_start: Instant, + spent_cents: i64, +} + +impl Usage { + /// Reset the window if `window` has elapsed since it started. + fn roll(&mut self, now: Instant, window: Duration) { + if now.duration_since(self.window_start) >= window { + self.window_start = now; + self.spent_cents = 0; + } + } +} + +/// Per-key token bucket for rate limiting. +#[derive(Debug)] +struct Bucket { + tokens: f64, + last_refill: Instant, +} + +impl Bucket { + /// Refill by elapsed time and try to consume one token. Returns true if allowed. + fn try_take(&mut self, now: Instant, rps: f64, burst: f64) -> bool { + let elapsed = now.duration_since(self.last_refill).as_secs_f64(); + self.tokens = (self.tokens + elapsed * rps).min(burst); + self.last_refill = now; + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } +} + +struct Inner { + enabled: bool, + base_url: String, + token: String, + http: reqwest::Client, + rules_cache: Cache>>, + usage: Mutex>, + buckets: Mutex>, + concurrency: Mutex>, +} + +/// Shared, cheaply-clonable spending-rules enforcer. Managed in Rocket state. +#[derive(Clone)] +pub struct SpendingRulesState { + inner: Arc, +} + +impl SpendingRulesState { + /// Build from env. Enforcement is enabled only when both + /// `LIT_PAYMENTS_INTERNAL_URL` and `INTERNAL_SERVICE_TOKEN` are set; + /// otherwise this is fully inert. + pub fn from_env() -> Self { + let base_url = std::env::var("LIT_PAYMENTS_INTERNAL_URL") + .ok() + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()); + let token = std::env::var("INTERNAL_SERVICE_TOKEN") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let enabled = base_url.is_some() && token.is_some(); + if enabled { + tracing::info!("spending_rules: enforcement enabled"); + } else { + tracing::info!( + "spending_rules: disabled (set LIT_PAYMENTS_INTERNAL_URL + INTERNAL_SERVICE_TOKEN to enable)" + ); + } + + let http = reqwest::Client::builder() + .timeout(FETCH_TIMEOUT) + .build() + .unwrap_or_default(); + + Self { + inner: Arc::new(Inner { + enabled, + base_url: base_url.unwrap_or_default(), + token: token.unwrap_or_default(), + http, + rules_cache: Cache::builder() + .max_capacity(RULES_CACHE_CAPACITY) + .time_to_live(RULES_CACHE_TTL) + .build(), + usage: Mutex::new(HashMap::new()), + buckets: Mutex::new(HashMap::new()), + concurrency: Mutex::new(HashMap::new()), + }), + } + } + + /// Enforce a flagged key's rules before execution. Returns an [`Admission`] + /// the caller holds across execution (releasing any concurrency permit on + /// drop) and calls [`Admission::record_spend`] on afterwards. + /// + /// `has_spending_rules` is the on-chain gate; pass it so we skip all work + /// (and the lit-payments round trip) for keys without rules. + pub async fn admit(&self, api_key: &str, has_spending_rules: bool) -> Result { + if !self.inner.enabled || !has_spending_rules { + return Ok(Admission(AdmissionInner::Noop)); + } + let hash = key_hash(api_key); + let rules = match self.fetch_rules(&hash).await { + Some(r) if r.enabled => r, + // no row, disabled, or fetch failed → don't block + _ => return Ok(Admission(AdmissionInner::Noop)), + }; + + let now = Instant::now(); + + // Rate limit. + if let (Some(rps), Some(burst)) = (rules.rate_limit_rps, rules.rate_limit_burst) { + let mut buckets = self.inner.buckets.lock().unwrap(); + let bucket = buckets.entry(hash.clone()).or_insert(Bucket { + tokens: burst as f64, + last_refill: now, + }); + if !bucket.try_take(now, rps as f64, burst as f64) { + return Err(ApiStatus::too_many_requests(format!( + "rate limit exceeded for this API key ({rps} rps)" + ))); + } + } + + // Rolling spend cap. + if let (Some(cap), Some(window)) = (rules.spend_cap_cents, rules.spend_window_seconds) { + let mut usage = self.inner.usage.lock().unwrap(); + let u = usage.entry(hash.clone()).or_insert(Usage { + window_start: now, + spent_cents: 0, + }); + u.roll(now, Duration::from_secs(window.max(0) as u64)); + if u.spent_cents >= cap { + return Err(ApiStatus::payment_required(format!( + "spending cap reached for this API key ({cap} cents / {window}s window)" + ))); + } + } + + // Concurrency (acquire last, so a rejection above never leaks a permit). + let concurrency_guard = if let Some(max) = rules.max_concurrency { + let mut counts = self.inner.concurrency.lock().unwrap(); + let count = counts.entry(hash.clone()).or_insert(0); + if *count >= max as u32 { + return Err(ApiStatus::too_many_requests(format!( + "max concurrent executions reached for this API key ({max})" + ))); + } + *count += 1; + Some(ConcurrencyGuard { + inner: self.inner.clone(), + key_hash: hash.clone(), + }) + } else { + None + }; + + Ok(Admission(AdmissionInner::Active { + inner: self.inner.clone(), + key_hash: hash, + window_secs: rules.spend_window_seconds, + _concurrency: concurrency_guard, + })) + } + + /// Fetch a key's rules (cached, TTL). `Ok(None)` (no row / disabled) is + /// cached so we don't refetch every request; network errors are not. + /// + /// TODO: upgrade to serve-stale-while-revalidate (background refresh), like + /// `stripe::get_credit_balance`, so the cold tick never blocks the hot path. + async fn fetch_rules(&self, hash: &str) -> Option> { + let cache = self.inner.rules_cache.clone(); + let inner = self.inner.clone(); + let hash_owned = hash.to_string(); + cache + .try_get_with(hash.to_string(), async move { + let url = format!("{}/internal/spending-rules/{}", inner.base_url, hash_owned); + let resp = inner + .http + .get(&url) + .bearer_auth(&inner.token) + .send() + .await + .map_err(|e| format!("spending_rules fetch failed: {e}"))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok::<_, String>(None); + } + if !resp.status().is_success() { + return Err(format!("spending_rules fetch status {}", resp.status())); + } + let body: RulesWithUsage = resp + .json() + .await + .map_err(|e| format!("spending_rules decode failed: {e}"))?; + // Seed the local rolling counter from the server's value. + if let Some(usage) = &body.usage { + seed_usage(&inner, &hash_owned, usage.spent_cents); + } + Ok(Some(Arc::new(body.rules))) + }) + .await + .unwrap_or_else(|e| { + tracing::warn!("spending_rules: {e}"); + None // fail open on transient error — never block a real call + }) + } + + fn add_local_spend(&self, hash: &str, cents: i64, window: i64) { + let now = Instant::now(); + let mut usage = self.inner.usage.lock().unwrap(); + let u = usage.entry(hash.to_string()).or_insert(Usage { + window_start: now, + spent_cents: 0, + }); + u.roll(now, Duration::from_secs(window.max(0) as u64)); + u.spent_cents = u.spent_cents.saturating_add(cents); + } + + /// Fire-and-forget POST of `cents` to lit-payments' rolling counter. + fn spawn_record(&self, hash: String, cents: i64, window: i64) { + let inner = self.inner.clone(); + tokio::spawn(async move { + let url = format!("{}/internal/spending-usage/{}/charge", inner.base_url, hash); + let res = inner + .http + .post(&url) + .bearer_auth(&inner.token) + .json(&serde_json::json!({ "cents": cents, "window_seconds": window })) + .send() + .await; + if let Err(e) = res { + tracing::warn!("spending_rules: record_spend POST failed: {e}"); + } + }); + } +} + +/// Seed/refresh the local counter from the server's value, taking the max so a +/// background refresh never undoes a local optimistic increment (cf. +/// `stripe::should_update_balance_cache`). +fn seed_usage(inner: &Inner, hash: &str, server_spent: i64) { + let now = Instant::now(); + let mut usage = inner.usage.lock().unwrap(); + let entry = usage.entry(hash.to_string()).or_insert(Usage { + window_start: now, + spent_cents: 0, + }); + entry.spent_cents = entry.spent_cents.max(server_spent); +} + +/// Held across execution. On drop, releases any concurrency permit. Call +/// [`Admission::record_seconds`] after execution to bill the rolling counter. +pub struct Admission(AdmissionInner); + +enum AdmissionInner { + Noop, + Active { + inner: Arc, + key_hash: String, + window_secs: Option, + _concurrency: Option, + }, +} + +impl Admission { + /// Record `seconds` of execution against the key's rolling spend (local + + /// async POST to lit-payments). No-op when there is no spend cap to enforce. + pub fn record_seconds(&self, seconds: u64) { + if let AdmissionInner::Active { + inner, + key_hash, + window_secs: Some(window), + .. + } = &self.0 + { + let cents = (seconds.max(1) as i64).saturating_mul(COST_PER_SECOND_CENTS); + let state = SpendingRulesState { + inner: inner.clone(), + }; + state.add_local_spend(key_hash, cents, *window); + state.spawn_record(key_hash.clone(), cents, *window); + } + } +} + +/// RAII concurrency permit: decrements the in-flight count on drop. +pub struct ConcurrencyGuard { + inner: Arc, + key_hash: String, +} + +impl Drop for ConcurrencyGuard { + fn drop(&mut self) { + let mut counts = self.inner.concurrency.lock().unwrap(); + if let Some(c) = counts.get_mut(&self.key_hash) { + *c = c.saturating_sub(1); + } + } +} + +/// The key's on-chain identity hash as 0x-prefixed 32-byte lowercase hex — +/// matching lit-payments' `canonical_key_hash`. +fn key_hash(api_key: &str) -> String { + let h = crate::utils::parse_with_hash::api_key_hash(api_key); + format!("0x{:0>64}", format!("{h:x}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bucket_allows_burst_then_throttles() { + let now = Instant::now(); + let mut b = Bucket { + tokens: 2.0, + last_refill: now, + }; + // Two tokens available, no time passing → two allowed, third denied. + assert!(b.try_take(now, 1.0, 2.0)); + assert!(b.try_take(now, 1.0, 2.0)); + assert!(!b.try_take(now, 1.0, 2.0)); + } + + #[test] + fn bucket_refills_over_time() { + let now = Instant::now(); + let mut b = Bucket { + tokens: 0.0, + last_refill: now, + }; + assert!(!b.try_take(now, 10.0, 10.0)); + // 0.5s at 10rps → ~5 tokens. + let later = now + Duration::from_millis(500); + assert!(b.try_take(later, 10.0, 10.0)); + } + + #[test] + fn usage_window_resets_after_elapse() { + let now = Instant::now(); + let mut u = Usage { + window_start: now, + spent_cents: 500, + }; + u.roll(now + Duration::from_secs(5), Duration::from_secs(10)); + assert_eq!(u.spent_cents, 500); // within window + u.roll(now + Duration::from_secs(11), Duration::from_secs(10)); + assert_eq!(u.spent_cents, 0); // window elapsed → reset + } + + #[test] + fn key_hash_is_0x_64_lowercase_hex() { + let h = key_hash("some-api-key"); + assert!(h.starts_with("0x")); + assert_eq!(h.len(), 66); + assert!(h[2..].bytes().all(|b| b.is_ascii_hexdigit())); + assert_eq!(h, h.to_lowercase()); + } +} diff --git a/lit-api-server/src/core/v1/endpoints/actions.rs b/lit-api-server/src/core/v1/endpoints/actions.rs index 0f977784..81babfa6 100644 --- a/lit-api-server/src/core/v1/endpoints/actions.rs +++ b/lit-api-server/src/core/v1/endpoints/actions.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::accounts::chain_config::ChainConfig; use crate::actions::grpc::GrpcClientPool; use crate::core::core_features; +use crate::core::spending_rules::SpendingRulesState; use crate::core::v1::guards::billing::BilledLitActionApiKey; use crate::core::v1::guards::cpu_overload::CpuAvailable; use crate::core::v1::helpers::api_status::{ApiResult, ErrMessage}; @@ -30,6 +31,7 @@ pub(super) async fn lit_action( http_client: &State, chain_config: &State>, stripe_state: &State>>, + spending: &State, lit_action_request: Json, ) -> OpenApiResponse { OpenApiResponse { @@ -42,6 +44,7 @@ pub(super) async fn lit_action( http_client.inner(), chain_config.inner().clone(), stripe_state.inner().clone(), + spending.inner(), lit_action_request, ) .await, diff --git a/lit-api-server/src/core/v1/helpers/api_status.rs b/lit-api-server/src/core/v1/helpers/api_status.rs index 0919438a..62dbfce0 100644 --- a/lit-api-server/src/core/v1/helpers/api_status.rs +++ b/lit-api-server/src/core/v1/helpers/api_status.rs @@ -170,6 +170,15 @@ impl ApiStatus { message, } } + + pub fn too_many_requests(message: impl Into) -> Self { + let message = message.into(); + warn!("too_many_requests: {:?}", message); + Self { + status: Status::TooManyRequests, + message, + } + } pub fn option_not_found(message: impl Into) -> Self { let message = message.into(); warn!("Option not found: {:?}", message); diff --git a/lit-api-server/src/main.rs b/lit-api-server/src/main.rs index 78efd868..1a624f24 100644 --- a/lit-api-server/src/main.rs +++ b/lit-api-server/src/main.rs @@ -366,6 +366,7 @@ fn build_rocket( .manage(chain_config) .manage(cpu_monitor) .manage(stripe_state) + .manage(core::spending_rules::SpendingRulesState::from_env()) .manage(core::v1::health::LitActionsSocketPath( std::path::PathBuf::from(core::v1::health::LIT_ACTIONS_SOCKET), )); From ba3148c6a743076f8ca3acdcfe5fa88730fec5a3 Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Wed, 17 Jun 2026 11:11:28 -0700 Subject: [PATCH 04/10] docs(plans): private-apps framework + lambda-parity gateway plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the two design plans from PR #446 into this branch so they ship with the lambda-parity implementation they describe, and #446 can be closed. - plans/chipotle-lambda-parity.md — prioritized gateway work (this PR implements P0.0/P0.1/P0.2/P1) for per-key blast-radius parity with a public Lambda URL. - plans/private-apps-backend.md — the backend-as-Lit-Actions framework these gateway controls unlock (relay becomes optional). Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/chipotle-lambda-parity.md | 351 +++++++++++++++++++++++++++ plans/private-apps-backend.md | 416 ++++++++++++++++++++++++++++++++ 2 files changed, 767 insertions(+) create mode 100644 plans/chipotle-lambda-parity.md create mode 100644 plans/private-apps-backend.md diff --git a/plans/chipotle-lambda-parity.md b/plans/chipotle-lambda-parity.md new file mode 100644 index 00000000..717dacef --- /dev/null +++ b/plans/chipotle-lambda-parity.md @@ -0,0 +1,351 @@ +# Chipotle: Lambda-Parity for Frontend-Callable Actions + +Status: design proposal / work plan +Author: Chris (with Claude) +Date: 2026-06-04 +Related: [private-apps-backend.md](./private-apps-backend.md) — this is the +gateway-side dependency that lets that plan drop its relay requirement. + +## Goal + +Make a Chipotle **usage API key safe to embed directly in a frontend**, with +abuse/spend blast-radius control on par with a **public AWS Lambda Function URL**. +Achieving this lets "private apps" host only a frontend and call Lit Actions +directly — no relay needed. + +## The framing (why this is achievable) + +A public Lambda URL is itself a credential-free, internet-callable endpoint. +Anyone can hit it; AWS's protection is **not secrecy**, it's **bounded, +configurable blast radius**: the URL invokes only that one function, throttled, +concurrency-capped, with budgets + alarms. A determined griefer can still run up +*capped* cost — parity means the worst case is a number you chose and got alerted +about, not "drains the account." + +Two things are notably **out of scope of the risk** and must stay that way: + +- **Confidentiality is unaffected.** Action plaintext/secrets are TEE- and + CID-gated. Griefing a public key burns money/availability; it can never read + encrypted data. Nothing here touches that guarantee. +- **CID scoping already bounds *what* runs.** Usage keys are group-scoped + (`execute_in_groups`), groups pin exact CIDs, and execute permission is checked + on-chain (`accounts/mod.rs::can_execute_action`). A leaked key on a group that + pins only your public actions can run **only those actions** — nothing else, + no account management. This is the "Function URL → one function" property, and + it already exists. + +So the gaps are all about bounding **rate, concurrency, and spend per key**, plus +defense-in-depth. + +## What already exists (verify + document, don't rebuild) + +| Capability | Where | Status | +|---|---|---| +| Per-key CID scoping (leak radius = pinned actions) | `accounts/mod.rs::can_execute_action`; group `cid_hashes` | ✅ works | +| Global overload shedding → 429 | `core/v1/guards/cpu_overload.rs` (CPL-202, commit d2743fdb) | ✅ but global, not per-key | +| Account-wide prepaid credits → 402 when empty | `core/v1/guards/billing.rs::BilledLitActionApiKey`; `stripe.rs` | ✅ but account-wide, crude | +| Per-second execution charge | `actions/client/execution.rs::flush_unbilled_seconds`; `stripe.rs` `COST_LIT_ACTION_PER_SECOND_CENTS` | ✅ | + +## Architecture: where state lives & the zero-latency path + +The hot path today is already built on one principle, and everything here follows +it: **authoritative data lives in a slow store (chain or Stripe); the request path +only ever touches an in-process [moka](https://docs.rs/moka) cache with TTL + +stale-while-revalidate (SWR) + request coalescing; staleness is tolerated within +the TTL; money/usage settles asynchronously off the response path.** We are not +inventing an architecture — we are adding cached fields that obey the existing one. + +What the code already establishes (verified): + +- **On-chain permission reads are already cached on the hot path.** + `accounts/blockchain_cache.rs` caches `canExecuteAction` / `canUseWalletInAction` + / wallet derivation (60-min TTL, per-account **generation-counter** invalidation + bumped on any write). The key's on-chain record is in memory when a request runs. +- **Stripe reads are cached too.** `stripe.rs`: `wallet_cache` (key→wallet, 1h), + `customer_cache` (10m), `balance_cache` (10m TTL + **SWR** + background refresh + + coalescing). The credit check in `BilledLitActionApiKey::from_request` is a + memory read after warmup. +- **Charging is off the response path.** `stripe::charge()` reads the cached + balance, does an **optimistic in-memory decrement**, records the event, and + `spawn`s the real Stripe balance transaction fire-and-forget (there's a + `billing.charge.settlement_failed` metric for when it doesn't land). + `flush_unbilled_seconds` awaits only the cache ops, not the network. So + per-request charging costs microseconds, not a round trip. + +### The zero-latency gate + +The on-chain key record is **already read and cached** on the hot path (it's what +`canExecuteAction` returns). So we pack a single **`hasSpendingRules` bit** into +that record. Reading it costs **zero** extra network and zero extra cache lookup — +it rides along in data already in memory. Fetched in the **same multicall** as +`canExecuteAction` (trivial Solidity change — return multiple values), even a cold +cache miss adds no extra round trip. + +``` +guard (before execution): + (canExec, hasSpendingRules) = blockchain_cache.permissions(keyHash) # 1 cached multicall + if !hasSpendingRules: return Success # ← the 99% with no caps: ZERO added latency + else: enforce rules (all in-memory; see below) +``` + +### Where each kind of state lives + +The three things we store have different shapes (config vs. counter, write-rarely +vs. write-per-request), so they don't share one home: + +| Data | Shape | Home | Hot-path read | +|---|---|---|---| +| `hasSpendingRules` flag | 1 bit, toggled rarely | **On-chain** (opt 2), in the key record | Free — already in `BlockchainCache` | +| Cap + window, rate/burst, concurrency, IP/origin allowlist | Config, edited occasionally | **lit-payments DB** (opt 3), edited via its backend+frontend | New moka cache (TTL+SWR), **only read when flag set** | +| Per-key cumulative spend (rolling window) | High-write counter, durable | **lit-payments DB**, written on the existing **async** charge path | Optimistic in-memory decrement (mirrors `balance_cache`) | +| Rate-limit token buckets, concurrency counts, per-IP counters | High-write, ephemeral, rolling | **In-process memory** (per-node) | Native — already in memory | + +**Why this split:** + +- **Flag on-chain, not the cap value.** The flag is a permission (belongs with the + others) and is the one thing that must be free to read for *everyone*. Toggled + only when a key gains/loses its first rule (1 tx, rare). Keeping the cap *value* + off-chain means tuning caps in the lit-payments UI needs no gas/tx, and the DB + read is gated behind the flag so non-cap accounts never touch it. +- **Rule details + per-key usage in the DB, not Stripe metadata.** Stripe metadata + is per-*customer* (account), not per-key; writing it is an API call; it's the + wrong tool for rolling windows, rate config, or analytics. Keep Stripe for the + money relationship that already exists (opt 1 stays as-is). +- **Counters in memory, per-node.** Rate buckets / concurrency counts can't go + on-chain (per-request writes) or in Stripe. Per-node is acceptable because the + **durable spend cap is the real backstop**; per-node rate limiting yields an + effective cluster rate of ≈ limit × N nodes, which is fine for griefing control + (AWS API Gateway throttling is approximate too). Start per-node; add a shared + store (Redis-like) only if cluster-exact limits are ever required. + +### Request flow for an opted-in key + +``` +1. blockchain_cache: (canExecuteAction, hasSpendingRules) 1 cached multicall +2. flag set → rules_cache.get(keyHash) memory; DB read only on cold miss (SWR) +3. spend_cache.get(keyHash) vs rolling cap memory; reject 402 if over +4. rate bucket + concurrency counter memory; reject 429/503 if over +5. origin / IP allowlist check memory; reject 403 if not allowed +6. execute +7. POST-response, in the existing spawned settlement task: + - Stripe balance txn (already there) + - increment per-key rolling spend in DB + optimistic in-mem decrement +``` + +Steps 1–5 are memory reads (low microseconds). Step 7 is entirely off the response +path. An opted-in key pays one cold DB read per ~TTL window; a no-cap key pays +nothing. The staleness tolerance is the same bargain CPL-246 already accepted for +balances: a tiny possible overspend inside the TTL window in exchange for a fast +hot path. + +### Resolved design decisions + +1. **Cap semantics → rolling window** (match AWS Budgets / Lambda), not a lifetime + balance. The per-key spend counter resets on the window boundary. +2. **Rules-cache freshness → SWR** (short TTL + background refresh, no cross-service + invalidation plumbing), mirroring `balance_cache`. A cap edit takes effect + within the TTL window. +3. **Counter durability → per-key spend reloads from the DB on cache miss** + (durable across deploys/restarts); rate/concurrency buckets may reset on restart + (acceptable — the spend cap is the durable backstop). +4. **`hasSpendingRules` is fetched in the same contract multicall as + `canExecuteAction`** so a cold cache miss adds no extra round trip (a simple + Solidity change to return multiple values). +5. **"Turn rules on" ordering:** write the DB rule first, *then* flip the on-chain + bit. The bit going true is what activates enforcement, so this ordering avoids a + window where the flag is set but rules aren't loaded. + +## Work items, in priority order + +Priority = how much it bounds the worst case per dollar of effort. P0 items are +the ones that turn "drains the account" into "burns a number you chose." + +--- + +### P0.0 — `hasSpendingRules` flag + multicall (foundation for everything below) + +**What.** A single `hasSpendingRules` bit on the on-chain key record, returned in +the **same multicall** as `canExecuteAction`, surfaced through `BlockchainCache`, +and a request guard that short-circuits to "no enforcement" when it's false. + +**Why first.** This is the zero-latency gate. Every per-key control below +(spend cap, rate, concurrency, origin) reads it to decide whether to do *any* extra +work. Land it first so the rest can assume "if we got here, rules exist." It also +guarantees the headline property: **keys with no rules pay zero added latency.** + +**Build.** +- Solidity: extend the `canExecuteAction` read to also return `hasSpendingRules` + (decision 4 — just more return values). +- `accounts/blockchain_cache.rs`: cache the bit alongside the existing + `execute_action` / `execute_and_wallet` results (same generation-counter + invalidation, so flipping it bumps the generation and is picked up immediately). +- A request guard (sibling to `BilledLitActionApiKey` / `cpu_overload`) that reads + the cached bit and returns `Success` immediately when false. + +**Acceptance.** A key with no rules executes with no extra reads beyond today's +cached permission check; flipping the bit on-chain takes effect on the next request +(generation bump), no redeploy. + +--- + +### P0.1 — Per-key spend cap with auto-disable ⭐ highest leverage + +**What.** A usage key carries its own budget (e.g. credits or a cents cap over a +window). Each execution decrements it; when exhausted the key is rejected (and +flagged disabled), independent of the account's overall balance. + +**Why it's #1.** This is the single control that converts the failure mode from +"a leaked frontend key can spend the whole account" into "a leaked key can spend +*its* budget, then stops." It's the analog of an AWS per-function/per-budget cap. + +See [Architecture: where state lives](#architecture-where-state-lives--the-zero-latency-path) +for the storage split this builds on. The cap is a **rolling window** (decision 1): +a per-key spend-to-date counter that resets on the window boundary, checked against +a cap configured in the lit-payments DB, gated by the on-chain `hasSpendingRules` +flag (P0.0) so non-cap keys pay zero latency. + +**Build.** +- **On-chain:** the `hasSpendingRules` bit lands via P0.0 (fetched in the + `canExecuteAction` multicall). The existing no-op `balance` field on the key + (`add_usage_api_key` in `core/v1/models/request.rs`; hardcoded `10_000_000` in + `account_management.rs`, never read) can be repurposed as the flag or retired. +- **DB (lit-payments):** store the cap + window per key; track the per-key rolling + spend-to-date. New endpoints on the lit-payments backend to read/set them. +- **Hot path (`guards/billing.rs`):** when the flag is set, read the cached rules + + cached per-key spend (both in-memory; cold miss = one DB read, SWR) and reject + with **402** if the rolling spend would exceed the cap. Mark the key disabled for + the remainder of the window so subsequent calls short-circuit. +- **Async (off response path):** in the existing spawned settlement task that + already writes Stripe (`stripe::charge`), also increment the per-key rolling + spend in the DB and apply the optimistic in-memory decrement (mirror + `balance_cache`). Per-key spend reloads from the DB on cache miss (decision 3). + +**Acceptance.** A key with a $X/window cap, hammered in a loop, stops at ~$X spent +within the window and is rejected until the window rolls; the account's other keys +and overall balance are untouched; a key with no rules sees no added latency. + +--- + +### P0.2 — Per-key + per-IP rate limiting (rate + burst) + +**What.** Token-bucket throttle keyed on the API key, and a coarser one keyed on +client IP, on the `/core/v1/lit_action` path. Configurable rate + burst per key. + +**Why.** Today the only throttle is the **global** CPU-overload 429 — it protects +the node, not your wallet, and one abusive key can still spend fast within global +capacity. This is API Gateway's per-key/per-stage throttling. + +**Build.** +- A request guard (sits with the other `core/v1/guards/`) that runs before + execution, after key resolution. Reuse the existing 429 response plumbing from + CPL-202 so OpenAPI/docs stay consistent. +- Limits per key default to sane values, overridable per key (store next to + `balance`). +- Per-IP limit as a second bucket (defense against many anonymous callers on one + public key). + +**Acceptance.** A key exceeding its configured rate gets 429 with a `Retry-After`; +other keys unaffected; the global CPU guard still independently sheds load. + +**Decided.** Bucket store is **in-process, per-node** (decision in Architecture): +effective cluster rate ≈ limit × N nodes, acceptable for griefing control since +the spend cap (P0.1) is the durable backstop. Add a shared store (Redis-like) only +if cluster-exact limits are ever required. Buckets gated behind `hasSpendingRules` +(P0.0) so non-rule keys are untouched. + +--- + +### P1 — Per-key concurrency cap (reserved-concurrency analog) + +**What.** Cap simultaneous in-flight executions per key. + +**Why.** Rate limits bound requests/sec; a concurrency cap bounds *simultaneous* +spend and node load — the thing AWS reserved concurrency exists for. Cheap ins- +urance once P0.2 exists; together they tightly bound burn velocity. + +**Build.** A counter (incremented at execution start, decremented at end/timeout) +in the execution dispatch path (`actions/client/execution.rs` / the dispatch in +`lit-actions/server`), checked against the key's configured max. Over-cap → +429/503. + +**Acceptance.** A key with concurrency=N never has >N actions running at once; +the N+1th waits or is rejected per policy. + +--- + +### P2.1 — Per-key origin allowlist (replace wildcard CORS) + +**What.** Each key (esp. public ones) carries an allowed-origins list; the gateway +sets/validates CORS against it instead of today's wildcard. + +**Why.** Today CORS is `AllowedOrigins::all()` (`main.rs`). An origin allowlist is +standard hygiene for a browser-callable endpoint and stops casual cross-site +reuse. **Defense-in-depth only** — `Origin`/`Referer` are browser-enforced and +trivially spoofed by non-browser clients, so it rides *on top of* P0.1/P0.2, +never instead of them. + +**Build.** Add `allowed_origins` to the key model; per-request CORS reflection + +rejection in the Rocket CORS layer / a guard. Empty list = same-as-today (or +deny, for `public` keys — see P2.2). + +**Acceptance.** A public key configured for `app.example.com` rejects browser +calls from other origins; server-to-server calls still work (documented as not a +security boundary). + +--- + +### P2.2 — "Public / frontend-safe" key type + Dashboard guardrails + +**What.** A flag marking a key as intended for the browser. The Dashboard (and +API) then *require* the blast-radius controls to be set before issuing it: a spend +cap (P0.1), rate limits (P0.2), concurrency cap (P1), and an origin allowlist +(P2.1). + +**Why.** Makes the safe path the default path. Without this, someone ships an +unbounded key to the browser and we're back to square one. This is the DX glue +that makes the whole effort land. + +**Build.** A `public: bool` (or key class) on the key model; validation that +public keys have non-default caps; Dashboard UX that surfaces "this key is +frontend-safe; here's your max blast radius: $X/day, N rps, M concurrent." + +**Acceptance.** Creating a public key without caps is refused with a clear message; +the Dashboard shows the bounded worst case for a public key at a glance. + +--- + +### P3 — Optional hardening (post-parity) + +- **Alerting / budget alarms.** Spend-velocity alerts and a notification when a + key auto-disables (the CloudWatch-alarm analog). Parity = bounded worst case + **+ you find out**. +- **App-check / PoW / captcha** for *unauthenticated* public endpoints, to raise + the cost of scripted abuse before it hits the spend cap. +- **Per-IP WAF-style rules** (geo, known-bad ranges, anomaly) beyond the P0.2 + bucket. +- **Gateway authorizer hook** for teams that want auth enforced at the edge in + addition to in-action. + +## Suggested sequencing + +``` +P0.0 hasSpendingRules flag + multicall ← foundation; the zero-latency gate +P0.1 per-key spend cap (rolling) + auto-disable ← biggest blast-radius cut +P0.2 per-key + per-IP rate/burst ← parallel-able with P0.1, both gated by P0.0 + └─ P1 per-key concurrency cap ← small add once P0.2 lands +P2.1 per-key origin allowlist ← independent; defense-in-depth +P2.2 public key type + Dashboard caps ← depends on P0.0/P0.1/P0.2/P1/P2.1 existing +P3 alarms / app-check / WAF / authz ← post-parity hardening +``` + +After **P0.0 + P0.1 + P0.2 + P1 + P2.2**, a usage key is safe to ship in a frontend with +bounded, configurable blast radius — Lambda parity — and +[private-apps-backend.md](./private-apps-backend.md) can make its relay optional. + +## The honest caveat (same one AWS has) + +None of this makes a public endpoint un-griefable. A determined attacker can run +up cost *within the caps you set*. Parity is **bounded, configurable worst case + +alerting**, not zero risk. The caps (P0/P1) make the worst case a number you chose; +the alarms (P3) make sure you hear about it. That is exactly what AWS reserved +concurrency + Budgets + CloudWatch deliver — and what this plan brings to Chipotle. diff --git a/plans/private-apps-backend.md b/plans/private-apps-backend.md new file mode 100644 index 00000000..de610bed --- /dev/null +++ b/plans/private-apps-backend.md @@ -0,0 +1,416 @@ +# Private Apps on Lit — A Backend-as-Lit-Actions Framework + +Status: design proposal / RFC +Author: Chris (with Claude) +Date: 2026-06-04 + +## The pitch + +Let a developer host only a frontend (Vercel, Netlify, S3, IPFS — anywhere) and +have **every backend route run as a Lit Action**, with application data living in +Postgres where **row contents are encrypted at rest and only ever decrypted +inside the TEE**, on an as-needed basis. Index fields stay plaintext so the +database can still filter, sort, and join. The result: a "serverless backend" +where the people running the database and the relay **cannot read the data**, and +where the exact code that touches plaintext is auditable by its IPFS CID. + +This is the natural generalization of `examples/dark-pool/` — which already +proves the "encrypted Postgres + decrypt-and-compute-in-TEE" mechanic for one +narrow use case. This plan turns that mechanic into a reusable **framework** for +building arbitrary private CRUD apps. + +## The form-factor question (the honest answer up front) + +> "Is the best form factor a JS library people import into their web app and use +> as the backend instead of hosting a backend elsewhere?" + +**Almost — but a pure frontend-only library is not possible, and it's worth being +precise about why.** Running a Lit Action requires a **usage API key** that gates +execution at the gateway (see `examples/dark-pool/scripts/setup.js:217` +`createUsageApiKey`, scoped to `execute_in_groups`). That key is a secret. If you +ship it in the browser bundle, anyone can pull it and run your actions / drain +your account. So you cannot eliminate the server entirely. + +What you **can** do is shrink the server to a **stateless ~50-line relay** that: + +- holds the usage API key, +- maps a route name → an action CID, +- forwards `{ route, args, userAuth }` to `POST /core/v1/lit_action`, +- returns the response verbatim. + +The relay holds **no application logic, no database credentials, and never sees +plaintext**. All of that lives in Lit Actions and the TEE. So the *effective* +answer to the user is: "you write your backend as a library of route handlers, +and the only thing you deploy yourself is a trivial relay (or use ours)." + +**Therefore the recommended form factor is a framework, not a single library** — +made of four pieces: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 1. @lit/private-app in-action runtime (imported into the │ +│ action code via jsDelivr ESM): │ +│ router · encrypted-ORM · Neon client · │ +│ auth/session verification · decrypt- │ +│ DB-url helper │ +│ │ +│ 2. lit-app (CLI) bundle routes → action(s), compute │ +│ CIDs, mint vault PKP + group + usage │ +│ key, run migrations, write config, │ +│ deploy the relay. Wraps setup.js. │ +│ │ +│ 3. @lit/private-app/client frontend SDK: typed RPC to your routes │ +│ through the relay; user auth helpers │ +│ │ +│ 4. relay stateless edge function (Cloudflare / │ +│ Vercel) OR a tiny Express app — holds │ +│ the usage key, forwards calls │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +(Working name TBD; "Salsa" / "Burrito" fit the existing `chipotle` theme of the +API host `api.chipotle.litprotocol.com`. Use a placeholder `@lit/private-app` +below.) + +## Non-goals + +- **Replacing Postgres-the-database.** We use a SQL-over-HTTP provider (Neon, or + any HTTP-fronted Postgres). A Lit Action only has `fetch` — it cannot open a + raw TCP Postgres socket (see `examples/dark-pool/README.md:79`). +- **Full encrypted-query / homomorphic search.** Equality search on sensitive + columns is supported via blind indexes; range queries and full-text search on + encrypted columns are out of scope. +- **Hiding metadata.** Plaintext index columns, row counts, and timing are + visible to the DB operator — same caveat as the dark pool + (`examples/dark-pool/README.md:102` privacy table). We document this loudly, + we don't pretend to solve it. +- **A new persistence engine inside the TEE.** Actions stay stateless; all state + is in Postgres. + +## How a developer builds an app (target DX) + +The whole point is that this should *feel* like writing Next.js API routes or a +tiny Express app — the privacy is mechanical, not something the dev hand-rolls. + +### 1. Define models — declare which fields are encrypted vs. indexed + +```js +// app/models.js +import { defineModel, indexed, encrypted } from "@lit/private-app"; + +export const Note = defineModel("notes", { + id: indexed.uuid(), // plaintext PK + owner: indexed.address(), // plaintext — filterable / joinable + createdAt: indexed.timestamp(), // plaintext — sortable + title: encrypted.text(), // sealed at rest + body: encrypted.text(), // sealed at rest + tags: encrypted.json(), // sealed at rest + emailHash: indexed.blind(), // HMAC blind index — equality-searchable + // without exposing the email +}); +``` + +- `indexed.*` → a real plaintext column. You can `WHERE` / `ORDER BY` / `JOIN` + on it. The DB operator can read it. +- `encrypted.*` → folded into **one** `ciphertext` column per row (see Data + model below). Never queryable; only visible inside the TEE. +- `indexed.blind()` → stores `HMAC(secret, value)`. Lets you do exact-match + lookups (`where({ emailHash: blind(email) })`) on an otherwise-secret value + without ever storing it in the clear. The HMAC key is itself an encrypted + secret decrypted in-action. + +### 2. Write routes — plain async handlers + +```js +// app/routes/notes.js +import { route } from "@lit/private-app"; +import { Note } from "../models.js"; + +export const create = route(async (ctx, { title, body, tags }) => { + const user = await ctx.requireUser(); // verifies the session + return Note.insert({ + owner: user.address, title, body, tags, createdAt: ctx.now, + }); // auto-encrypts title/body/tags +}); + +export const list = route(async (ctx, { limit = 20, cursor }) => { + const user = await ctx.requireUser(); + // Filters on the PLAINTEXT `owner` index, paginates, then decrypts only the + // rows on this page — keeps decrypt count bounded (see Performance below). + return Note.where({ owner: user.address }) + .orderBy("createdAt", "desc") + .paginate({ limit, cursor }) + .all(); // auto-decrypts the page +}); + +export const remove = route(async (ctx, { id }) => { + const user = await ctx.requireUser(); + const note = await Note.find(id); + if (!note || note.owner !== user.address) throw ctx.forbidden(); + return Note.delete(id); +}); +``` + +### 3. Deploy + +```bash +npx lit-app deploy +``` + +The CLI (wrapping the `examples/dark-pool/scripts/setup.js` flow) does: + +1. Bundle each route file into Lit Action source (the `@lit/private-app` + in-action runtime is inlined via the existing SWC bundler / jsDelivr imports). +2. Compute each action's CID (`get_lit_action_ipfs_id`). +3. Mint the **vault PKP** (encrypts row contents + the DB URL + the blind-index + HMAC key) — `create_wallet`. +4. Create a **group**, add the PKP, **pin every route's CID** (no wildcard — + `cid_hashes_permitted: []` then explicit `add_action_to_group`, exactly as + dark-pool does at `setup.js:99-104`). +5. Mint a **usage API key** scoped to `execute_in_groups: [groupId]`. +6. Encrypt `DATABASE_URL` (and the HMAC key) against the vault PKP; store only + ciphertext. +7. Run migrations to create the plaintext index columns + the `ciphertext` + column for each model. +8. Write `.lit-app/config.json` (route → CID map, PKP id, encrypted DB url, + group id) and deploy/print the relay with the usage key as its only secret. + +### 4. Call it from the frontend + +```js +// web/api.js +import { createClient } from "@lit/private-app/client"; +export const api = createClient({ url: "/api" }); // points at the relay + +// anywhere in the app: +await api.notes.create({ title: "secret", body: "...", tags: ["x"] }); +const page = await api.notes.list({ limit: 20 }); +``` + +`api.notes.create` → `POST /api` `{ route: "notes.create", args, auth }` → relay +adds the usage key → `/core/v1/lit_action` with `js_params` → the `notes.create` +action runs in the TEE → returns the result. + +## Architecture + +``` + browser (frontend only) relay (stateless, ~50 LOC) + ┌────────────────────┐ POST /api ┌───────────────────────┐ + │ @lit/private-app/ │ {route,args,auth} │ holds usage API key │ + │ client ├─────────────────────►│ route → CID lookup │ + │ - typed RPC │ │ injects api key │ + │ - user auth (sign │ └──────────┬────────────┘ + │ in w/ wallet/JWT)│ │ POST /core/v1/lit_action + └────────────────────┘ │ {code|ipfs_id, js_params} + ▼ + ┌──────────────────────────────┐ + │ TEE: Lit Action (one per │ + │ route, pinned CID) │ + │ @lit/private-app runtime: │ + │ 1. verify ctx.user (auth) │ + │ 2. Decrypt(DB url) │ + │ 3. query Neon over HTTPS │ + │ (filter on plaintext idx) │ + │ 4. Encrypt on write / │ + │ Decrypt the page on read │ + │ 5. return result (no secrets)│ + └───────────────┬──────────────┘ + │ SQL over HTTPS + ▼ + ┌──────────────────────────────┐ + │ Neon Postgres │ + │ idx cols: plaintext │ + │ ciphertext col: opaque blob │ + └──────────────────────────────┘ +``` + +The relay operator and the DB operator each see only ciphertext + plaintext index +metadata. Plaintext app data exists **only** in TEE memory during a call. + +## Data model: one ciphertext per row + +The single most important design decision, driven by Lit's limits (below): +**bundle all `encrypted.*` columns of a row into one JSON blob and seal it as a +single ciphertext column.** Not one ciphertext per field. + +```sql +CREATE TABLE notes ( + id uuid PRIMARY KEY, + owner text NOT NULL, -- indexed.address + created_at timestamptz NOT NULL, -- indexed.timestamp + email_hash bytea, -- indexed.blind (HMAC) + ciphertext text NOT NULL -- Encrypt(JSON{title, body, tags}) +); +CREATE INDEX ON notes (owner, created_at DESC); +``` + +- **Write** = 1 `Lit.Actions.Encrypt` call per row. +- **Read** = 1 `Lit.Actions.Decrypt` call per row returned. + +This keeps the count of cryptographic remote ops equal to *rows touched*, not +*rows × encrypted-columns*. The ORM hides the (de)serialization; the dev just +sees fields. + +Cost of this choice: rotating or selectively disclosing a single encrypted field +means re-encrypting the whole row blob (acceptable for app data; documented). + +## Auth & per-user isolation + +Two layers, both needed: + +1. **Gateway** — the relay's usage key is required to run any action at all. + Random internet traffic without the relay can't execute routes. +2. **In-action user identity** — `ctx.requireUser()` verifies an end-user + credential *inside the action*. Options the runtime supports: + - **Wallet signature**: client signs a session challenge (nonce + deadline); + the action recovers it with `ethers.utils.verifyMessage` and exposes + `ctx.user.address` (the pattern in `docs/lit-actions/patterns.mdx:231` and + `examples/dark-pool` order auth). Also available: `Lit.Auth.authSigAddress`. + - **App JWT / session token**: action `fetch`es the app's auth endpoint to + verify, à la `docs/lit-actions/patterns.mdx:318`. + +Row ownership is then enforced in the handler (`note.owner === ctx.user.address`) +— the gateway proves *a* legitimate caller; the in-action check proves *which* +user and what they may touch. This mirrors dark-pool's "the usage key lets you +run the action, but per-record authority comes from a signature verified in the +enclave." + +**Stronger isolation (optional, phase 4+):** a **vault PKP per user** so each +user's rows are encrypted under a distinct key (`docs/lit-actions/patterns.mdx:291` +shows `user-alice-data-vault`). This gives crypto-level blast-radius isolation but +multiplies key/group management and runs into PKP-per-user scale; default to +**one app vault PKP** and gate by `owner`, offer per-user vaults as an advanced +mode. + +## Performance & limits (the real constraints) + +From `docs/lit-actions/limits.mdx`: + +| Limit | Default | Implication for the framework | +|---|---|---| +| Execution time | 15 min | fine for CRUD | +| Memory | 64 MB | bounds page size / payload | +| Outbound HTTP per action | **50** | each Neon query is 1 fetch; budget queries per route | +| Response payload | **1 MB** | hard cap on a page of decrypted rows | +| Console log | 100 KB | never log plaintext anyway | +| **Key/signature requests per action** | **10** | ⚠️ **see open question** | + +Two things shape the ORM: + +1. **Decrypt is a remote op.** Each `Decrypt`/`Encrypt` is a gRPC round-trip to + the node (`lit-actions/ext/bindings.rs`), and recent work explicitly targets + this cost (`a8a69edd perf: avoid deferred Lit Action remote ops`). So: filter + and paginate on **plaintext index columns first**, then decrypt only the rows + you actually return. Never "decrypt the table to filter it." + +2. **The per-action key/signature cap.** Docs say **10**. But `matchEpoch.js` + decrypts the DB URL **plus every order in a batch (up to 200)** in a single + action and works — so either `Decrypt` against a PKP-derived symmetric key + does **not** count toward that cap, or the cap is raised per account. **This is + load-bearing and must be confirmed** (see Open questions). The framework should + (a) minimize decrypts regardless, and (b) expose a `maxPageSize` the CLI can + tune to the account's real cap; if the cap genuinely binds at ~10, the default + page size shrinks and large lists paginate. + +Other notes: +- **No connection pooling** (stateless actions) — Neon HTTP is per-call; this is + inherent and acceptable for Neon's serverless driver. +- **Cold start** — first call to a freshly-bundled action pays bundle/CID + resolution; warm thereafter (the API server LRU-caches code by CID). + +## Security & trust model (state it plainly, like dark-pool does) + +- **You trust the TEE.** Plaintext exists in enclave memory during a call. + TEEs have known side-channel attacks (`examples/dark-pool/README.md:170`). +- **You trust the pinned action CIDs.** A permitted action *can* exfiltrate + plaintext if its code chooses to (`docs/lit-actions/secrets.mdx:18`). The + framework's value is that the in-action runtime is open and the deployed CIDs + are auditable — `lit-app deploy` prints them, and you can diff a CID against + the published `@lit/private-app` version. **The CLI must make "what code is + pinned" trivially verifiable**, or the privacy claim is hand-wavy. +- **Metadata leaks.** Index columns, row counts, and timing are visible to the + DB operator. `indexed.blind()` mitigates *value* exposure for equality columns + but not existence/count/timing. Document per-model what's plaintext. +- **The relay is untrusted for confidentiality** (sees only ciphertext-bound + traffic) but **is trusted for availability and rate-limiting** (it holds the + usage key; if compromised, an attacker can run your actions / burn your + account quota, but cannot read data). Recommend the relay also rate-limits and + optionally checks the user credential cheaply before forwarding. + +## Where this lives / build order + +Mirror how `dark-pool` proved the mechanic before generalizing: + +- **Phase 0 — Reference app (validates the whole pattern end-to-end).** + Add `examples/private-app-starter/` — a runnable private notes (or chat) app: + models, a few routes, the setup script (forked from dark-pool's), a tiny relay, + and a minimal frontend. No framework abstraction yet; hand-written. Proves + encrypt-on-write / decrypt-the-page / plaintext-index-filter / wallet-auth all + work together and surfaces the real decrypt-count behavior. + +- **Phase 1 — `@lit/private-app` in-action runtime.** + Extract from Phase 0: `defineModel`, `indexed`/`encrypted`/`blind` field types, + the query builder (`where/orderBy/paginate/insert/find/delete`), the Neon HTTP + client (lifted from `matchEpoch.js:253`), the row encrypt/decrypt codec, the + `route()` wrapper + `ctx` (`requireUser`, `now`, `forbidden`). Lives under + `lit-actions/packages/` next to `naga-la-types`. Ship TS types. + +- **Phase 2 — `lit-app` CLI.** + Generalize `dark-pool/scripts/setup.js` into a project-aware tool: discover + models + routes, bundle, compute CIDs, mint PKP/group/usage key, pin CIDs, run + migrations, write `.lit-app/config.json`, print/deploy the relay. Add + `lit-app migrate`, `lit-app verify` (re-derive and diff pinned CIDs), and + `lit-app dev` (local loop). + +- **Phase 3 — client SDK + relay templates.** + `@lit/private-app/client` (`createClient`, typed route proxies, wallet/JWT + sign-in). Relay templates for Cloudflare Workers, Vercel Edge, and a tiny + Express app (this repo is literally `lit-node-express` — an Express template is + the natural reference). + +- **Phase 4 — hardening / advanced.** + Per-user vault PKPs; blind-index range tricks; migration/rotation tooling; a + `lit-triggers`-driven background-job story (cron/webhook routes, since triggers + already exist in `examples/lit-triggers/`); request-level rate limiting in the + relay. + +## Open questions (resolve before/within Phase 0) + +1. **Does `Lit.Actions.Decrypt` count toward the per-action 10 key/signature + cap?** `matchEpoch.js` decrypting ~200 rows says no (or the cap is raised). + This single answer sets the default `maxPageSize` and whether large reads must + chunk across multiple action calls. Confirm with the runtime/limits owner; + measure in Phase 0. +2. **One mega-router action vs. one action per route.** Per-route CIDs give + finer audit + permission granularity (you can pin/unpin a single route) but + more CIDs to manage and re-pin on change; a single router action is simpler + but any route edit re-CIDs the whole backend. Lean **per-route** for + auditability; let the CLI bundle small apps into one action as an option. +3. **Blind-index HMAC key custody.** Store it as an encrypted secret under the + vault PKP (decrypted in-action) — confirm that's acceptable vs. a separate + vault, and define rotation (rotating re-derives every blind index → a + migration job). +4. **Migrations against an encrypted column.** Adding/removing an `encrypted.*` + field changes the row-blob shape. Define a backfill/migration story (lazy + re-encrypt on next write vs. a one-shot migration action that pages through + and re-seals rows — itself bounded by the decrypt cap). +5. **Where does user-auth verification belong** — purely in-action (max trust, + every call pays a `fetch` to the auth service) vs. partly in the relay (cheap + pre-check, but the relay becomes slightly trusted)? Default in-action; + document the relay pre-check as an optimization. +6. **Multi-tenant relay vs. self-hosted.** Offer a hosted relay (Lit-run) so devs + truly deploy "frontend only," with self-hosting as the escape hatch. Decide if + the hosted relay is in scope. + +## TL;DR recommendation + +Ship a **framework** (`@lit/private-app` in-action runtime + `lit-app` CLI + +client SDK + a stateless relay template), not a single browser library — because +the usage API key forces a thin server, but that server can be a logic-free, +plaintext-blind relay. Model data as **plaintext index columns + one sealed +ciphertext blob per row**, filter/paginate on the indexes and decrypt only the +returned page, and gate every route with a gateway usage key *plus* an in-action +user-identity check. Prove it first as `examples/private-app-starter/` (a private +notes app), then extract the framework. The dark pool already demonstrates every +primitive this needs; this plan is mostly about packaging them into a DX a +developer can adopt in an afternoon. From b615115fa703a0d5b75faee15279efcc10014e4b Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 15 Sep 2026 12:20:19 -0700 Subject: [PATCH 05/10] fix(lit-payments): compile after main merge; accept LIT_INTERNAL_SHARED_SECRET for /internal The five hand-built test Configs were missing the new internal_service_token field. Also let the spending /internal endpoints fall back to the LIT_INTERNAL_SHARED_SECRET that lit-api-server and lit-payments already share, so enabling spending rules needs no second secret. Co-Authored-By: Claude Fable 5.1 --- lit-payments/src/auto_topup/reconciler_tests.rs | 1 + lit-payments/src/auto_topup/webhook/handler_tests.rs | 1 + lit-payments/src/billing/auto_topup_config_tests.rs | 1 + lit-payments/src/billing/sca_resume_tests.rs | 1 + lit-payments/src/billing/setup_intent_tests.rs | 1 + lit-payments/src/config.rs | 8 +++++--- 6 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lit-payments/src/auto_topup/reconciler_tests.rs b/lit-payments/src/auto_topup/reconciler_tests.rs index eb65f91c..641056ab 100644 --- a/lit-payments/src/auto_topup/reconciler_tests.rs +++ b/lit-payments/src/auto_topup/reconciler_tests.rs @@ -57,6 +57,7 @@ fn test_config(stripe_secret_key: String, db_url: String) -> Config { enterprise_billing_interval_secs: 3600, stripe_dashboard_base: "https://dashboard.stripe.com".to_string(), cors_allowed_origins: vec!["http://localhost".to_string()], + internal_service_token: None, gas_funder: None, } } diff --git a/lit-payments/src/auto_topup/webhook/handler_tests.rs b/lit-payments/src/auto_topup/webhook/handler_tests.rs index 0aa27408..f38cf197 100644 --- a/lit-payments/src/auto_topup/webhook/handler_tests.rs +++ b/lit-payments/src/auto_topup/webhook/handler_tests.rs @@ -83,6 +83,7 @@ fn test_config(stripe_secret_key: String, db_url: String) -> Config { enterprise_billing_interval_secs: 3600, stripe_dashboard_base: "https://dashboard.stripe.com".to_string(), cors_allowed_origins: vec!["http://localhost".to_string()], + internal_service_token: None, gas_funder: None, } } diff --git a/lit-payments/src/billing/auto_topup_config_tests.rs b/lit-payments/src/billing/auto_topup_config_tests.rs index 27ffd724..c866fb4d 100644 --- a/lit-payments/src/billing/auto_topup_config_tests.rs +++ b/lit-payments/src/billing/auto_topup_config_tests.rs @@ -77,6 +77,7 @@ fn test_config(stripe_secret_key: String, db_url: String) -> Config { enterprise_billing_interval_secs: 3600, stripe_dashboard_base: "https://dashboard.stripe.com".to_string(), cors_allowed_origins: vec!["http://localhost".to_string()], + internal_service_token: None, gas_funder: None, } } diff --git a/lit-payments/src/billing/sca_resume_tests.rs b/lit-payments/src/billing/sca_resume_tests.rs index fabd5b95..b6ef7438 100644 --- a/lit-payments/src/billing/sca_resume_tests.rs +++ b/lit-payments/src/billing/sca_resume_tests.rs @@ -55,6 +55,7 @@ fn test_config(stripe_secret_key: String, db_url: String) -> Config { enterprise_billing_interval_secs: 3600, stripe_dashboard_base: "https://dashboard.stripe.com".to_string(), cors_allowed_origins: vec!["http://localhost".to_string()], + internal_service_token: None, gas_funder: None, } } diff --git a/lit-payments/src/billing/setup_intent_tests.rs b/lit-payments/src/billing/setup_intent_tests.rs index a313f1d9..b041fd70 100644 --- a/lit-payments/src/billing/setup_intent_tests.rs +++ b/lit-payments/src/billing/setup_intent_tests.rs @@ -63,6 +63,7 @@ fn test_config(stripe_secret_key: String) -> Config { enterprise_billing_interval_secs: 3600, stripe_dashboard_base: "https://dashboard.stripe.com".to_string(), cors_allowed_origins: vec!["http://localhost".to_string()], + internal_service_token: None, gas_funder: None, } } diff --git a/lit-payments/src/config.rs b/lit-payments/src/config.rs index 24291a0a..5764249e 100644 --- a/lit-payments/src/config.rs +++ b/lit-payments/src/config.rs @@ -49,8 +49,9 @@ pub struct Config { pub litkey_chain: Option, /// Shared bearer token authenticating the gateway's internal calls to the /// spending-rules endpoints (`/internal/*`). If unset, those endpoints are - /// disabled (503) — they are never left open. See - /// `crate::spending::service_auth`. + /// disabled (503) — they are never left open. Falls back to + /// `LIT_INTERNAL_SHARED_SECRET` (already shared with lit-api-server) when + /// `INTERNAL_SERVICE_TOKEN` is unset. See `crate::spending::service_auth`. pub internal_service_token: Option, /// Base URL of `lit-api-server`, used for the auto-top-up cache /// invalidation callback after a successful credit. e.g., @@ -211,7 +212,8 @@ impl Config { max_daily_per_operator_cents: optional_i64("MAX_DAILY_PER_OPERATOR_CENTS", 10_000)?, litkey_discount_basis_points: parse_discount_basis_points()?, litkey_chain: parse_litkey_chain_config()?, - internal_service_token: optional_trimmed("INTERNAL_SERVICE_TOKEN"), + internal_service_token: optional_trimmed("INTERNAL_SERVICE_TOKEN") + .or_else(|| optional_trimmed("LIT_INTERNAL_SHARED_SECRET")), lit_api_server_base_url: required("LIT_API_SERVER_BASE_URL")? .trim_end_matches('/') .to_string(), From 203762b3c79eaf8ead5e0acdeb2069234d9e2d69 Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 15 Sep 2026 12:20:19 -0700 Subject: [PATCH 06/10] feat(spending-rules): origin allowlist (P2.1) + per-client-IP rate limit (P0.2) Gateway enforcement for flagged keys now also checks: - allowed_origins: the browser Origin must match an entry (scheme://host[:port], leading *. wildcard on host). Missing Origin -> 403. - ip_rate_limit_rps/burst: a second token bucket keyed by (key, client IP), using Rocket's client_ip (X-Real-IP behind the ingress; see guards::rate_limit for the trust model). Per-IP buckets are swept when idle past 1h. Both are read from the same lit-payments row (new columns + validation). A SpendingContext request guard carries Origin + client IP into admit(). /lit_binary_action now runs the same combined execute+spending check and admission as /lit_action so a frontend key can't sidestep its rules there. Fixes the post-merge compile break (can_execute_action import). Co-Authored-By: Claude Fable 5.1 --- lit-api-server/src/core/core_features.rs | 42 +- lit-api-server/src/core/spending_rules.rs | 558 +++++++++++++++++- .../src/core/v1/endpoints/actions.rs | 7 + lit-api-server/src/core/v1/guards/mod.rs | 1 + .../src/core/v1/guards/request_meta.rs | 57 ++ .../20260604000001_spending_rules.sql | 6 + lit-payments/src/spending/db.rs | 31 +- lit-payments/src/spending/types.rs | 90 ++- 8 files changed, 734 insertions(+), 58 deletions(-) create mode 100644 lit-api-server/src/core/v1/guards/request_meta.rs diff --git a/lit-api-server/src/core/core_features.rs b/lit-api-server/src/core/core_features.rs index 9aef5f30..44a8b789 100644 --- a/lit-api-server/src/core/core_features.rs +++ b/lit-api-server/src/core/core_features.rs @@ -1,6 +1,5 @@ use crate::accounts::can_execute_action_with_spending_rules; use crate::accounts::chain_config::{ChainConfig, ConfigKeys}; -use crate::core::spending_rules::SpendingRulesState; use crate::actions::client::ClientBuilder; use crate::actions::client::models::DenoExecutionEnv; use crate::actions::client::{ @@ -10,6 +9,8 @@ use crate::actions::client::{ }; use crate::actions::grpc::GrpcClientPool; use crate::core::cache_metadata::CacheMetadataIndex; +use crate::core::spending_rules::SpendingRulesState; +use crate::core::v1::guards::request_meta::SpendingContext; use crate::core::v1::helpers::api_status::ApiStatus; use crate::core::v1::models::request::{LitActionRequest, LitBinaryActionRequest}; use crate::core::v1::models::response::{ @@ -41,6 +42,7 @@ pub async fn lit_action( chain_config: Arc, stripe_state: Option>, spending: &SpendingRulesState, + spending_ctx: &SpendingContext, lit_action_request: Json, ) -> Result { let request_id = request_span.request_id.clone(); @@ -61,9 +63,10 @@ pub async fn lit_action( // Single multicall returns the execute permission AND the zero-latency // spending-rules gate. has_spending_rules is false for almost every key, so // the spending-rules path below is skipped entirely for them. - let (can_execute, has_spending_rules) = can_execute_action_with_spending_rules(api_key, cid_hash) - .instrument(tracing::debug_span!("lit_action::can_execute_action")) - .await?; + let (can_execute, has_spending_rules) = + can_execute_action_with_spending_rules(api_key, cid_hash) + .instrument(tracing::debug_span!("lit_action::can_execute_action")) + .await?; if !can_execute { let msg = format!( "The provided API key is not authorized to execute the specified action ({derived_ipfs_id}/{cid_hash})." @@ -71,10 +74,13 @@ pub async fn lit_action( return Err(ApiStatus::forbidden(msg)); } - // Enforce per-key spending rules (rolling cap / rate / concurrency) before - // execution. Inert unless the key is flagged AND enforcement is configured. - // The returned admission holds any concurrency permit until end of scope. - let admission = spending.admit(api_key, has_spending_rules).await?; + // Enforce per-key spending rules (origin / rate / per-IP / rolling cap / + // concurrency) before execution. Inert unless the key is flagged AND + // enforcement is configured. The returned admission holds any concurrency + // permit until end of scope. + let admission = spending + .admit(api_key, has_spending_rules, spending_ctx) + .await?; // Cache after authorization so unauthorized requests cannot pollute the cache. ipfs_cache @@ -259,6 +265,8 @@ pub async fn lit_binary_action( http_client: &reqwest::Client, chain_config: Arc, stripe_state: Option>, + spending: &SpendingRulesState, + spending_ctx: &SpendingContext, gvisor_socket: PathBuf, request: Json, ) -> Result { @@ -276,17 +284,23 @@ pub async fn lit_binary_action( // the derived IPFS CID: `can_execute_action` keccak-hashes the id string, // so on-chain registration of the same bundle bytes matches here. let cid_hash = ipfs_cid_to_u256(&checksum)?; - let can_execute = can_execute_action(api_key, cid_hash) - .instrument(tracing::debug_span!( - "lit_binary_action::can_execute_action" - )) - .await?; + // Same combined check as the JS path: a frontend-safe key must not be able + // to sidestep its spending rules by calling the binary runner instead. + let (can_execute, has_spending_rules) = + can_execute_action_with_spending_rules(api_key, cid_hash) + .instrument(tracing::debug_span!( + "lit_binary_action::can_execute_action" + )) + .await?; if !can_execute { let msg = format!( "The provided API key is not authorized to execute the specified action ({checksum}/{cid_hash})." ); return Err(ApiStatus::forbidden(msg)); } + let admission = spending + .admit(api_key, has_spending_rules, spending_ctx) + .await?; // The gVisor runner still routes ops (fetch, key derivation, …) back // through this server's op handlers, so wire the same execution env the JS @@ -334,6 +348,7 @@ pub async fn lit_binary_action( .filter(|s| !s.trim().is_empty()), }; + let exec_start = std::time::Instant::now(); let result = match client .execute_js(execution_options) .instrument(tracing::debug_span!("lit_binary_action::execute_js")) @@ -342,6 +357,7 @@ pub async fn lit_binary_action( Ok(result) => result, Err(e) => return Err(anyhow::anyhow!("action execution failed: {e:#}").into()), }; + admission.record_seconds(exec_start.elapsed().as_secs_f64().ceil() as u64); let response = match serde_json::from_str::(&result.response) { Ok(response) => response, diff --git a/lit-api-server/src/core/spending_rules.rs b/lit-api-server/src/core/spending_rules.rs index b96cbe7c..fec42269 100644 --- a/lit-api-server/src/core/spending_rules.rs +++ b/lit-api-server/src/core/spending_rules.rs @@ -6,24 +6,35 @@ //! are fetched (cached) from lit-payments' `/internal` endpoints and enforced //! before execution: //! +//! - **origin allowlist** (403 when the browser `Origin` is missing or not +//! listed — P2.1; trivially spoofed by non-browser clients, so it is +//! defense-in-depth on top of the limits below), +//! - **rate limit** — per-node token bucket per key (429), +//! - **per-IP rate limit** — per-node token bucket per (key, client IP) (429), //! - **rolling spend cap** (402 when reached), -//! - **rate limit** — per-node token bucket (429), //! - **concurrency cap** — per-node in-flight counter (429). //! //! Spend is recorded back to lit-payments off the response path. Counters are //! in-process and per-node — acceptable because the durable spend cap is the //! real backstop. See `plans/chipotle-lambda-parity.md`. //! -//! Inert until configured: if `LIT_PAYMENTS_INTERNAL_URL` / -//! `INTERNAL_SERVICE_TOKEN` are unset, `admit` always allows and records nothing. +//! This module also owns the gateway → lit-payments *write* path used by the +//! account-management endpoints (`set_rules` / `delete_rules`), so the +//! lit-payments URL + token are configured in exactly one place. +//! +//! Inert until configured: if `LIT_PAYMENTS_INTERNAL_URL` is unset (or neither +//! `INTERNAL_SERVICE_TOKEN` nor `LIT_INTERNAL_SHARED_SECRET` is), `admit` +//! always allows and records nothing, and the write path returns +//! [`SpendingRulesError::NotConfigured`]. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use moka::future::Cache; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use crate::core::v1::guards::request_meta::SpendingContext; use crate::core::v1::helpers::api_status::ApiStatus; /// SWR-ish freshness for the rules cache (kept short; only flagged keys pay it). @@ -34,28 +45,93 @@ const FETCH_TIMEOUT: Duration = Duration::from_secs(2); /// Per-second execution cost, mirrored from the Stripe charge rate so the /// per-key counter tracks roughly what the account is billed. const COST_PER_SECOND_CENTS: i64 = crate::stripe::COST_LIT_ACTION_PER_SECOND_CENTS; +/// Per-(key, IP) buckets are unbounded in principle (one per distinct client), +/// so once the map passes this size, entries idle longer than +/// [`IP_BUCKET_IDLE`] are swept on the next insert. +const IP_BUCKET_SWEEP_THRESHOLD: usize = 50_000; +const IP_BUCKET_IDLE: Duration = Duration::from_secs(3600); /// The rules the gateway enforces for one key (subset of the lit-payments row). -#[derive(Debug, Clone, Deserialize)] +/// `Default` is "no limits, enabled" — the same as an empty JSON object. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuleSet { pub spend_cap_cents: Option, pub spend_window_seconds: Option, pub rate_limit_rps: Option, pub rate_limit_burst: Option, pub max_concurrency: Option, + #[serde(default)] + pub ip_rate_limit_rps: Option, + #[serde(default)] + pub ip_rate_limit_burst: Option, + /// `scheme://host[:port]` entries; a leading `*.` on the host matches any + /// subdomain. `None`/empty = no origin check. + #[serde(default)] + pub allowed_origins: Option>, + #[serde(default = "default_true")] pub enabled: bool, } +fn default_true() -> bool { + true +} + +impl Default for RuleSet { + fn default() -> Self { + Self { + spend_cap_cents: None, + spend_window_seconds: None, + rate_limit_rps: None, + rate_limit_burst: None, + max_concurrency: None, + ip_rate_limit_rps: None, + ip_rate_limit_burst: None, + allowed_origins: None, + enabled: true, + } + } +} + +/// Errors from the gateway → lit-payments write path (`set_rules` / +/// `delete_rules`). Mapped to HTTP by the account-management layer. +#[derive(Debug)] +pub enum SpendingRulesError { + /// `LIT_PAYMENTS_INTERNAL_URL` / token not set on this node. + NotConfigured, + /// lit-payments rejected the rules (400 with its message). + Rejected(String), + /// lit-payments unreachable / 5xx / undecodable. + Upstream(String), +} + +impl std::fmt::Display for SpendingRulesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotConfigured => write!( + f, + "spending rules are not configured on this node (LIT_PAYMENTS_INTERNAL_URL)" + ), + Self::Rejected(m) => write!(f, "spending rules rejected: {m}"), + Self::Upstream(m) => write!(f, "spending rules service error: {m}"), + } + } +} + +impl std::error::Error for SpendingRulesError {} + /// Shape of `GET /internal/spending-rules/` from lit-payments. -#[derive(Debug, Deserialize)] -struct RulesWithUsage { - rules: RuleSet, - usage: Option, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RulesWithUsage { + pub rules: RuleSet, + pub usage: Option, } -#[derive(Debug, Deserialize)] -struct UsageRow { - spent_cents: i64, +/// Current rolling-window usage as stored by lit-payments. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageRow { + pub spent_cents: i64, + #[serde(default)] + pub window_started_at: Option, } /// Local rolling-spend counter, seeded from lit-payments and incremented on each @@ -106,6 +182,9 @@ struct Inner { rules_cache: Cache>>, usage: Mutex>, buckets: Mutex>, + /// Keyed by `"|"`; swept when large (see + /// [`IP_BUCKET_SWEEP_THRESHOLD`]). + ip_buckets: Mutex>, concurrency: Mutex>, } @@ -116,25 +195,29 @@ pub struct SpendingRulesState { } impl SpendingRulesState { - /// Build from env. Enforcement is enabled only when both - /// `LIT_PAYMENTS_INTERNAL_URL` and `INTERNAL_SERVICE_TOKEN` are set; - /// otherwise this is fully inert. + /// Build from env. Enforcement is enabled only when + /// `LIT_PAYMENTS_INTERNAL_URL` and a token (`INTERNAL_SERVICE_TOKEN`, or + /// the existing `LIT_INTERNAL_SHARED_SECRET` already shared with + /// lit-payments) are set; otherwise this is fully inert. pub fn from_env() -> Self { - let base_url = std::env::var("LIT_PAYMENTS_INTERNAL_URL") - .ok() - .map(|s| s.trim().trim_end_matches('/').to_string()) - .filter(|s| !s.is_empty()); - let token = std::env::var("INTERNAL_SERVICE_TOKEN") - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); + let env_trimmed = |k: &str| { + std::env::var(k) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }; + let base_url = + env_trimmed("LIT_PAYMENTS_INTERNAL_URL").map(|s| s.trim_end_matches('/').to_string()); + let token = env_trimmed("INTERNAL_SERVICE_TOKEN") + .or_else(|| env_trimmed("LIT_INTERNAL_SHARED_SECRET")); let enabled = base_url.is_some() && token.is_some(); if enabled { tracing::info!("spending_rules: enforcement enabled"); } else { tracing::info!( - "spending_rules: disabled (set LIT_PAYMENTS_INTERNAL_URL + INTERNAL_SERVICE_TOKEN to enable)" + "spending_rules: disabled (set LIT_PAYMENTS_INTERNAL_URL + INTERNAL_SERVICE_TOKEN \ + or LIT_INTERNAL_SHARED_SECRET to enable)" ); } @@ -155,18 +238,30 @@ impl SpendingRulesState { .build(), usage: Mutex::new(HashMap::new()), buckets: Mutex::new(HashMap::new()), + ip_buckets: Mutex::new(HashMap::new()), concurrency: Mutex::new(HashMap::new()), }), } } + /// Whether this node can reach lit-payments' spending-rules store. + pub fn is_configured(&self) -> bool { + self.inner.enabled + } + /// Enforce a flagged key's rules before execution. Returns an [`Admission`] /// the caller holds across execution (releasing any concurrency permit on /// drop) and calls [`Admission::record_spend`] on afterwards. /// /// `has_spending_rules` is the on-chain gate; pass it so we skip all work - /// (and the lit-payments round trip) for keys without rules. - pub async fn admit(&self, api_key: &str, has_spending_rules: bool) -> Result { + /// (and the lit-payments round trip) for keys without rules. `ctx` carries + /// the request's `Origin` and client IP for the allowlist / per-IP checks. + pub async fn admit( + &self, + api_key: &str, + has_spending_rules: bool, + ctx: &SpendingContext, + ) -> Result { if !self.inner.enabled || !has_spending_rules { return Ok(Admission(AdmissionInner::Noop)); } @@ -179,7 +274,26 @@ impl SpendingRulesState { let now = Instant::now(); - // Rate limit. + // Origin allowlist (cheapest check, and a rejected origin should not + // consume rate-limit tokens the legitimate frontend needs). + if let Some(allowed) = rules.allowed_origins.as_deref().filter(|a| !a.is_empty()) { + match ctx.origin.as_deref() { + Some(origin) if origin_allowed(origin, allowed) => {} + Some(origin) => { + return Err(ApiStatus::forbidden(format!( + "origin {origin} is not allowed to use this API key" + ))); + } + None => { + return Err(ApiStatus::forbidden( + "this API key may only be used from an allowed browser origin \ + (missing Origin header)", + )); + } + } + } + + // Per-key rate limit. if let (Some(rps), Some(burst)) = (rules.rate_limit_rps, rules.rate_limit_burst) { let mut buckets = self.inner.buckets.lock().unwrap(); let bucket = buckets.entry(hash.clone()).or_insert(Bucket { @@ -193,6 +307,28 @@ impl SpendingRulesState { } } + // Per-(key, client IP) rate limit. With no resolvable client IP every + // caller shares one bucket — never more permissive than the per-key + // limit, matching the trust model in `guards::rate_limit`. + if let (Some(rps), Some(burst)) = (rules.ip_rate_limit_rps, rules.ip_rate_limit_burst) { + let ip = ctx + .client_ip + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let ip_key = format!("{hash}|{ip}"); + let mut buckets = self.inner.ip_buckets.lock().unwrap(); + sweep_idle_buckets(&mut buckets, now); + let bucket = buckets.entry(ip_key).or_insert(Bucket { + tokens: burst as f64, + last_refill: now, + }); + if !bucket.try_take(now, rps as f64, burst as f64) { + return Err(ApiStatus::too_many_requests(format!( + "rate limit exceeded for this API key from your address ({rps} rps per IP)" + ))); + } + } + // Rolling spend cap. if let (Some(cap), Some(window)) = (rules.spend_cap_cents, rules.spend_window_seconds) { let mut usage = self.inner.usage.lock().unwrap(); @@ -276,6 +412,111 @@ impl SpendingRulesState { }) } + /// Drop the cached rules (and local counters) for a key so the next + /// request sees a fresh row. Called after `set_rules` / `delete_rules`. + pub async fn invalidate(&self, api_key_or_hash: &str) { + let hash = key_hash_from_key_or_hash(api_key_or_hash); + self.inner.rules_cache.invalidate(&hash).await; + self.inner.usage.lock().unwrap().remove(&hash); + self.inner.buckets.lock().unwrap().remove(&hash); + self.inner.concurrency.lock().unwrap().remove(&hash); + let prefix = format!("{hash}|"); + self.inner + .ip_buckets + .lock() + .unwrap() + .retain(|k, _| !k.starts_with(&prefix)); + } + + /// Current rules + usage for a key, straight from lit-payments (uncached — + /// this is the account-owner read path, not the hot path). `Ok(None)` when + /// the key has no rules. + pub async fn get_rules( + &self, + api_key_or_hash: &str, + ) -> Result, SpendingRulesError> { + if !self.inner.enabled { + return Err(SpendingRulesError::NotConfigured); + } + let hash = key_hash_from_key_or_hash(api_key_or_hash); + let url = format!("{}/internal/spending-rules/{}", self.inner.base_url, hash); + let resp = self + .inner + .http + .get(&url) + .bearer_auth(&self.inner.token) + .send() + .await + .map_err(|e| SpendingRulesError::Upstream(e.to_string()))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let resp = check_upstream(resp).await?; + resp.json::() + .await + .map(Some) + .map_err(|e| SpendingRulesError::Upstream(format!("decode failed: {e}"))) + } + + /// Create/replace a key's rules in lit-payments. The caller (account + /// management) is responsible for flipping the on-chain flag afterwards. + pub async fn set_rules( + &self, + api_key_or_hash: &str, + rules: &RuleSet, + ) -> Result { + if !self.inner.enabled { + return Err(SpendingRulesError::NotConfigured); + } + let hash = key_hash_from_key_or_hash(api_key_or_hash); + let url = format!("{}/internal/spending-rules/{}", self.inner.base_url, hash); + let resp = self + .inner + .http + .put(&url) + .bearer_auth(&self.inner.token) + .json(rules) + .send() + .await + .map_err(|e| SpendingRulesError::Upstream(e.to_string()))?; + let resp = check_upstream(resp).await?; + let stored = resp + .json::() + .await + .map_err(|e| SpendingRulesError::Upstream(format!("decode failed: {e}")))?; + self.invalidate(&hash).await; + Ok(stored) + } + + /// Delete a key's rules + usage counter in lit-payments. Returns whether a + /// row existed. + pub async fn delete_rules(&self, api_key_or_hash: &str) -> Result { + if !self.inner.enabled { + return Err(SpendingRulesError::NotConfigured); + } + let hash = key_hash_from_key_or_hash(api_key_or_hash); + let url = format!("{}/internal/spending-rules/{}", self.inner.base_url, hash); + let resp = self + .inner + .http + .delete(&url) + .bearer_auth(&self.inner.token) + .send() + .await + .map_err(|e| SpendingRulesError::Upstream(e.to_string()))?; + let resp = check_upstream(resp).await?; + #[derive(Deserialize)] + struct Deleted { + deleted: bool, + } + let body = resp + .json::() + .await + .map_err(|e| SpendingRulesError::Upstream(format!("decode failed: {e}")))?; + self.invalidate(&hash).await; + Ok(body.deleted) + } + fn add_local_spend(&self, hash: &str, cents: i64, window: i64) { let now = Instant::now(); let mut usage = self.inner.usage.lock().unwrap(); @@ -306,6 +547,96 @@ impl SpendingRulesState { } } +/// Map a lit-payments response to our error type: 4xx → `Rejected` with the +/// server's message, 5xx → `Upstream`. +async fn check_upstream(resp: reqwest::Response) -> Result { + let status = resp.status(); + if status.is_success() { + return Ok(resp); + } + #[derive(Deserialize)] + struct ErrBody { + error: String, + } + let msg = match resp.json::().await { + Ok(b) => b.error, + Err(_) => status.to_string(), + }; + if status.is_client_error() { + Err(SpendingRulesError::Rejected(msg)) + } else { + Err(SpendingRulesError::Upstream(format!("{status}: {msg}"))) + } +} + +/// Evict idle per-IP buckets once the map is large. O(n) but only runs when +/// the map has grown past the threshold, so amortised cost stays small. +fn sweep_idle_buckets(buckets: &mut HashMap, now: Instant) { + if buckets.len() >= IP_BUCKET_SWEEP_THRESHOLD { + buckets.retain(|_, b| now.duration_since(b.last_refill) < IP_BUCKET_IDLE); + } +} + +/// Does the request `Origin` match one of the allowlist entries? +/// +/// Both sides are compared as `scheme://host[:port]` with scheme and host +/// lowercased (the browser sends them lowercased already). An entry whose host +/// starts with `*.` matches any subdomain (one or more labels) of the +/// remainder, on the same scheme and port, but not the bare apex. +pub fn origin_allowed(origin: &str, allowed: &[String]) -> bool { + let Some(origin) = normalize_origin(origin) else { + return false; + }; + allowed.iter().any(|entry| { + let Some(entry) = normalize_origin(entry) else { + return false; + }; + if let Some((scheme, wild_host)) = entry.split_once("://*.") { + let Some((o_scheme, o_host)) = origin.split_once("://") else { + return false; + }; + if o_scheme != scheme { + return false; + } + // Split host[:port] on both sides so the port must match exactly. + let (o_h, o_p) = split_host_port(o_host); + let (w_h, w_p) = split_host_port(wild_host); + o_p == w_p && o_h.len() > w_h.len() && o_h.ends_with(&format!(".{w_h}")) + } else { + origin == entry + } + }) +} + +/// Lowercase scheme+host, strip a single trailing slash, reject anything with +/// a path/query (which a real `Origin` header never carries). +fn normalize_origin(raw: &str) -> Option { + let s = raw.trim().trim_end_matches('/'); + let (scheme, rest) = s.split_once("://")?; + if rest.is_empty() || rest.contains(['/', '?', '#', ' ']) { + return None; + } + Some(format!( + "{}://{}", + scheme.to_ascii_lowercase(), + rest.to_ascii_lowercase() + )) +} + +/// `host[:port]` → `(host, Option)`; a bracketed IPv6 literal is kept whole. +fn split_host_port(s: &str) -> (&str, Option<&str>) { + if s.starts_with('[') { + return match s.rsplit_once("]:") { + Some((h, p)) => (&s[..h.len() + 1], Some(p)), + None => (s, None), + }; + } + match s.rsplit_once(':') { + Some((h, p)) if !h.contains(':') => (h, Some(p)), + _ => (s, None), + } +} + /// Seed/refresh the local counter from the server's value, taking the max so a /// background refresh never undoes a local optimistic increment (cf. /// `stripe::should_update_balance_cache`). @@ -376,6 +707,13 @@ fn key_hash(api_key: &str) -> String { format!("0x{:0>64}", format!("{h:x}")) } +/// Same as [`key_hash`], but accepts an already-hashed 32-byte hex key (as +/// returned by `list_api_keys`) and passes it through canonicalised. +fn key_hash_from_key_or_hash(s: &str) -> String { + let h = crate::utils::parse_with_hash::usage_api_key_to_hash(s); + format!("0x{:0>64}", format!("{h:x}")) +} + #[cfg(test)] mod tests { use super::*; @@ -419,6 +757,172 @@ mod tests { assert_eq!(u.spent_cents, 0); // window elapsed → reset } + fn ctx(origin: Option<&str>, ip: Option<&str>) -> SpendingContext { + SpendingContext { + origin: origin.map(str::to_string), + client_ip: ip.map(|s| s.parse().unwrap()), + } + } + + #[test] + fn origin_exact_match_is_case_insensitive_and_port_sensitive() { + let allowed = vec!["https://app.example.com".to_string()]; + assert!(origin_allowed("https://app.example.com", &allowed)); + assert!(origin_allowed("HTTPS://App.Example.COM/", &allowed)); + assert!(!origin_allowed("http://app.example.com", &allowed)); + assert!(!origin_allowed("https://app.example.com:8443", &allowed)); + assert!(!origin_allowed("https://evil.example.com", &allowed)); + assert!(!origin_allowed( + "https://app.example.com.evil.com", + &allowed + )); + assert!(!origin_allowed("null", &allowed)); + } + + #[test] + fn origin_wildcard_matches_subdomains_not_apex() { + let allowed = vec!["https://*.example.com".to_string()]; + assert!(origin_allowed("https://app.example.com", &allowed)); + assert!(origin_allowed("https://a.b.example.com", &allowed)); + assert!(!origin_allowed("https://example.com", &allowed)); + assert!(!origin_allowed("https://notexample.com", &allowed)); + assert!(!origin_allowed("http://app.example.com", &allowed)); + assert!(!origin_allowed("https://app.example.com:3000", &allowed)); + let with_port = vec!["http://*.localhost:3000".to_string()]; + assert!(origin_allowed("http://dev.localhost:3000", &with_port)); + assert!(!origin_allowed("http://dev.localhost", &with_port)); + } + + #[test] + fn split_host_port_handles_ipv6() { + assert_eq!(split_host_port("[::1]:3000"), ("[::1]", Some("3000"))); + assert_eq!(split_host_port("[::1]"), ("[::1]", None)); + assert_eq!(split_host_port("localhost:80"), ("localhost", Some("80"))); + assert_eq!(split_host_port("localhost"), ("localhost", None)); + } + + #[test] + fn sweep_only_runs_past_threshold_and_keeps_active() { + let now = Instant::now(); + let mut m = HashMap::new(); + m.insert( + "stale".to_string(), + Bucket { + tokens: 0.0, + last_refill: now - IP_BUCKET_IDLE * 2, + }, + ); + sweep_idle_buckets(&mut m, now); + assert_eq!(m.len(), 1, "below threshold: nothing swept"); + for i in 0..IP_BUCKET_SWEEP_THRESHOLD { + m.insert( + format!("live{i}"), + Bucket { + tokens: 1.0, + last_refill: now, + }, + ); + } + sweep_idle_buckets(&mut m, now); + assert!(!m.contains_key("stale")); + assert_eq!(m.len(), IP_BUCKET_SWEEP_THRESHOLD); + } + + /// A state that is "configured" (so `admit` runs) but whose rules cache is + /// pre-seeded, so no network call happens. + async fn seeded_state(rules: RuleSet) -> (SpendingRulesState, &'static str) { + let key = "test-usage-key"; + let state = SpendingRulesState { + inner: Arc::new(Inner { + enabled: true, + base_url: "http://127.0.0.1:1".into(), + token: "t".into(), + http: reqwest::Client::new(), + rules_cache: Cache::builder().build(), + usage: Mutex::new(HashMap::new()), + buckets: Mutex::new(HashMap::new()), + ip_buckets: Mutex::new(HashMap::new()), + concurrency: Mutex::new(HashMap::new()), + }), + }; + state + .inner + .rules_cache + .insert(key_hash(key), Some(Arc::new(rules))) + .await; + (state, key) + } + + #[tokio::test] + async fn admit_enforces_origin_allowlist() { + let (state, key) = seeded_state(RuleSet { + allowed_origins: Some(vec!["https://app.example.com".into()]), + ..Default::default() + }) + .await; + assert!( + state + .admit(key, true, &ctx(Some("https://app.example.com"), None)) + .await + .is_ok() + ); + let denied = state + .admit(key, true, &ctx(Some("https://evil.com"), None)) + .await; + assert_eq!( + denied.err().map(|e| e.status), + Some(rocket::http::Status::Forbidden) + ); + let missing = state.admit(key, true, &ctx(None, None)).await; + assert_eq!( + missing.err().map(|e| e.status), + Some(rocket::http::Status::Forbidden) + ); + // Unflagged keys skip everything, even with a bad origin. + assert!( + state + .admit(key, false, &ctx(Some("https://evil.com"), None)) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn admit_enforces_per_ip_bucket_independently() { + let (state, key) = seeded_state(RuleSet { + ip_rate_limit_rps: Some(1), + ip_rate_limit_burst: Some(1), + ..Default::default() + }) + .await; + assert!( + state + .admit(key, true, &ctx(None, Some("10.0.0.1"))) + .await + .is_ok() + ); + let throttled = state.admit(key, true, &ctx(None, Some("10.0.0.1"))).await; + assert_eq!( + throttled.err().map(|e| e.status), + Some(rocket::http::Status::TooManyRequests) + ); + // A different client still has its own burst. + assert!( + state + .admit(key, true, &ctx(None, Some("10.0.0.2"))) + .await + .is_ok() + ); + // invalidate() clears the per-IP buckets for the key. + state.invalidate(key).await; + assert!( + state + .admit(key, true, &ctx(None, Some("10.0.0.1"))) + .await + .is_ok() + ); + } + #[test] fn key_hash_is_0x_64_lowercase_hex() { let h = key_hash("some-api-key"); diff --git a/lit-api-server/src/core/v1/endpoints/actions.rs b/lit-api-server/src/core/v1/endpoints/actions.rs index 4c6b826c..cf8285ce 100644 --- a/lit-api-server/src/core/v1/endpoints/actions.rs +++ b/lit-api-server/src/core/v1/endpoints/actions.rs @@ -8,6 +8,7 @@ use crate::core::core_features; use crate::core::spending_rules::SpendingRulesState; use crate::core::v1::guards::billing::BilledLitActionApiKey; use crate::core::v1::guards::cpu_overload::CpuAvailable; +use crate::core::v1::guards::request_meta::SpendingContext; use crate::core::v1::health::LitActionsGvisorSocketPath; use crate::core::v1::helpers::api_status::{ApiResult, ErrMessage}; use crate::core::v1::helpers::open_api_response::OpenApiResponse; @@ -36,6 +37,7 @@ pub(super) async fn lit_action( chain_config: &State>, stripe_state: &State>>, spending: &State, + spending_ctx: SpendingContext, lit_action_request: Json, ) -> OpenApiResponse { OpenApiResponse { @@ -50,6 +52,7 @@ pub(super) async fn lit_action( chain_config.inner().clone(), stripe_state.inner().clone(), spending.inner(), + &spending_ctx, lit_action_request, ) .await, @@ -81,6 +84,8 @@ pub(super) async fn lit_binary_action( http_client: &State, chain_config: &State>, stripe_state: &State>>, + spending: &State, + spending_ctx: SpendingContext, gvisor_socket: &State, request: Json, ) -> OpenApiResponse { @@ -94,6 +99,8 @@ pub(super) async fn lit_binary_action( http_client.inner(), chain_config.inner().clone(), stripe_state.inner().clone(), + spending.inner(), + &spending_ctx, gvisor_socket.0.clone(), request, ) diff --git a/lit-api-server/src/core/v1/guards/mod.rs b/lit-api-server/src/core/v1/guards/mod.rs index 99e00ac5..2149714e 100644 --- a/lit-api-server/src/core/v1/guards/mod.rs +++ b/lit-api-server/src/core/v1/guards/mod.rs @@ -2,3 +2,4 @@ pub mod apikey; pub mod billing; pub mod cpu_overload; pub mod rate_limit; +pub mod request_meta; diff --git a/lit-api-server/src/core/v1/guards/request_meta.rs b/lit-api-server/src/core/v1/guards/request_meta.rs new file mode 100644 index 00000000..964cd757 --- /dev/null +++ b/lit-api-server/src/core/v1/guards/request_meta.rs @@ -0,0 +1,57 @@ +//! [`SpendingContext`] — the per-request facts the spending-rules enforcer +//! needs beyond the API key: the browser `Origin` header (origin allowlist) and +//! the client IP (per-IP rate limit). +//! +//! Infallible: it never rejects a request on its own. Enforcement (and the +//! decision to fail closed on a missing `Origin`) lives in +//! [`crate::core::spending_rules`], and only runs for keys whose on-chain +//! `hasSpendingRules` flag is set. +//! +//! The client IP comes from [`rocket::Request::client_ip`], i.e. Rocket's +//! configured `ip_header` (default `X-Real-IP`) with a socket-peer fallback. +//! See the trust-model note in [`super::rate_limit`]: behind the dstack ingress +//! the per-IP key is only meaningful if the proxy overwrites that header. + +use std::net::IpAddr; + +use rocket::request::{FromRequest, Outcome, Request}; +use rocket_okapi::Result as RocketOkapiResult; +use rocket_okapi::r#gen::OpenApiGenerator; +use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput}; + +/// Request facts consumed by `SpendingRulesState::admit`. +#[derive(Debug, Clone, Default)] +pub struct SpendingContext { + /// Raw `Origin` header, if the client sent one. Browsers always do for + /// cross-origin `fetch`; curl/servers usually do not. + pub origin: Option, + /// Best-effort client address (see module docs for the trust model). + pub client_ip: Option, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for SpendingContext { + type Error = (); + + async fn from_request(req: &'r Request<'_>) -> Outcome { + Outcome::Success(SpendingContext { + origin: req + .headers() + .get_one("Origin") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + client_ip: req.client_ip(), + }) + } +} + +impl<'r> OpenApiFromRequest<'r> for SpendingContext { + fn from_request_input( + _generator: &mut OpenApiGenerator, + _name: String, + _required: bool, + ) -> RocketOkapiResult { + // `Origin` is set by the browser, not the caller — not a documented param. + Ok(RequestHeaderInput::None) + } +} diff --git a/lit-payments/migrations/20260604000001_spending_rules.sql b/lit-payments/migrations/20260604000001_spending_rules.sql index 5e4b0186..4e67c44e 100644 --- a/lit-payments/migrations/20260604000001_spending_rules.sql +++ b/lit-payments/migrations/20260604000001_spending_rules.sql @@ -25,6 +25,9 @@ CREATE TABLE spending_rules ( -- Max simultaneous in-flight executions. NULL = no concurrency cap. max_concurrency INTEGER CHECK (max_concurrency IS NULL OR max_concurrency > 0), + -- Per-client-IP token bucket (P0.2), enforced in addition to the per-key one. + ip_rate_limit_rps INTEGER CHECK (ip_rate_limit_rps IS NULL OR ip_rate_limit_rps > 0), + ip_rate_limit_burst INTEGER CHECK (ip_rate_limit_burst IS NULL OR ip_rate_limit_burst > 0), -- Browser origin allowlist (defense-in-depth). NULL/empty = no restriction. allowed_origins TEXT[], @@ -42,6 +45,9 @@ CREATE TABLE spending_rules ( -- A rate limit needs both halves or neither. CONSTRAINT rate_limit_complete CHECK ( (rate_limit_rps IS NULL) = (rate_limit_burst IS NULL) + ), + CONSTRAINT ip_rate_limit_complete CHECK ( + (ip_rate_limit_rps IS NULL) = (ip_rate_limit_burst IS NULL) ) ); diff --git a/lit-payments/src/spending/db.rs b/lit-payments/src/spending/db.rs index cc79aff5..37924393 100644 --- a/lit-payments/src/spending/db.rs +++ b/lit-payments/src/spending/db.rs @@ -16,8 +16,9 @@ pub async fn upsert_rules( let row = sqlx::query_as::<_, SpendingRules>( "INSERT INTO spending_rules ( api_key_hash, account_wallet_address, spend_cap_cents, spend_window_seconds, - rate_limit_rps, rate_limit_burst, max_concurrency, allowed_origins, enabled, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) + rate_limit_rps, rate_limit_burst, max_concurrency, + ip_rate_limit_rps, ip_rate_limit_burst, allowed_origins, enabled, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now()) ON CONFLICT (api_key_hash) DO UPDATE SET account_wallet_address = EXCLUDED.account_wallet_address, spend_cap_cents = EXCLUDED.spend_cap_cents, @@ -25,6 +26,8 @@ pub async fn upsert_rules( rate_limit_rps = EXCLUDED.rate_limit_rps, rate_limit_burst = EXCLUDED.rate_limit_burst, max_concurrency = EXCLUDED.max_concurrency, + ip_rate_limit_rps = EXCLUDED.ip_rate_limit_rps, + ip_rate_limit_burst = EXCLUDED.ip_rate_limit_burst, allowed_origins = EXCLUDED.allowed_origins, enabled = EXCLUDED.enabled, updated_at = now() @@ -37,6 +40,8 @@ pub async fn upsert_rules( .bind(req.rate_limit_rps) .bind(req.rate_limit_burst) .bind(req.max_concurrency) + .bind(req.ip_rate_limit_rps) + .bind(req.ip_rate_limit_burst) .bind(req.allowed_origins.as_deref()) .bind(req.enabled) .fetch_one(pool) @@ -45,22 +50,20 @@ pub async fn upsert_rules( } pub async fn get_rules(pool: &PgPool, api_key_hash: &str) -> Result> { - let row = sqlx::query_as::<_, SpendingRules>( - "SELECT * FROM spending_rules WHERE api_key_hash = $1", - ) - .bind(api_key_hash) - .fetch_optional(pool) - .await?; + let row = + sqlx::query_as::<_, SpendingRules>("SELECT * FROM spending_rules WHERE api_key_hash = $1") + .bind(api_key_hash) + .fetch_optional(pool) + .await?; Ok(row) } pub async fn get_usage(pool: &PgPool, api_key_hash: &str) -> Result> { - let row = sqlx::query_as::<_, SpendingUsage>( - "SELECT * FROM spending_usage WHERE api_key_hash = $1", - ) - .bind(api_key_hash) - .fetch_optional(pool) - .await?; + let row = + sqlx::query_as::<_, SpendingUsage>("SELECT * FROM spending_usage WHERE api_key_hash = $1") + .bind(api_key_hash) + .fetch_optional(pool) + .await?; Ok(row) } diff --git a/lit-payments/src/spending/types.rs b/lit-payments/src/spending/types.rs index be80ab95..29cfce6a 100644 --- a/lit-payments/src/spending/types.rs +++ b/lit-payments/src/spending/types.rs @@ -18,6 +18,8 @@ pub struct SpendingRules { pub rate_limit_rps: Option, pub rate_limit_burst: Option, pub max_concurrency: Option, + pub ip_rate_limit_rps: Option, + pub ip_rate_limit_burst: Option, pub allowed_origins: Option>, pub enabled: bool, #[serde(with = "time::serde::rfc3339")] @@ -54,6 +56,15 @@ pub struct UpsertRulesRequest { pub rate_limit_burst: Option, #[serde(default)] pub max_concurrency: Option, + /// Per-client-IP token bucket, enforced in addition to the per-key one. + #[serde(default)] + pub ip_rate_limit_rps: Option, + #[serde(default)] + pub ip_rate_limit_burst: Option, + /// Browser `Origin` values allowed to use this key. `None`/empty = no + /// origin check. Entries are `scheme://host[:port]`; a leading `*.` on the + /// host matches any subdomain. Requests without an `Origin` header are + /// rejected when this is set. #[serde(default)] pub allowed_origins: Option>, /// Defaults to enabled when omitted. @@ -106,6 +117,9 @@ impl UpsertRulesRequest { if self.rate_limit_rps.is_some() != self.rate_limit_burst.is_some() { return Err("rate_limit_rps and rate_limit_burst must be set together".into()); } + if self.ip_rate_limit_rps.is_some() != self.ip_rate_limit_burst.is_some() { + return Err("ip_rate_limit_rps and ip_rate_limit_burst must be set together".into()); + } for (name, v) in [ ("spend_cap_cents", self.spend_cap_cents), ("spend_window_seconds", self.spend_window_seconds), @@ -120,6 +134,8 @@ impl UpsertRulesRequest { ("rate_limit_rps", self.rate_limit_rps), ("rate_limit_burst", self.rate_limit_burst), ("max_concurrency", self.max_concurrency), + ("ip_rate_limit_rps", self.ip_rate_limit_rps), + ("ip_rate_limit_burst", self.ip_rate_limit_burst), ] { if let Some(v) = v && v <= 0 @@ -127,15 +143,48 @@ impl UpsertRulesRequest { return Err(format!("{name} must be positive")); } } - if let Some(origins) = &self.allowed_origins - && origins.iter().any(|o| o.trim().is_empty()) - { - return Err("allowed_origins must not contain empty entries".into()); + if let Some(origins) = &self.allowed_origins { + for o in origins { + validate_origin_pattern(o)?; + } } Ok(()) } } +/// An allowed-origin entry must be `scheme://host[:port]` with no path, query +/// or fragment — exactly the shape a browser sends in `Origin`. The host may +/// start with `*.` to match any subdomain. Rejects empty/whitespace entries. +pub fn validate_origin_pattern(raw: &str) -> Result<(), String> { + let o = raw.trim(); + if o.is_empty() { + return Err("allowed_origins must not contain empty entries".into()); + } + let Some((scheme, rest)) = o.split_once("://") else { + return Err(format!( + "allowed_origins entry {o:?} must be scheme://host[:port]" + )); + }; + if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") { + return Err(format!( + "allowed_origins entry {o:?} must use http or https" + )); + } + if rest.is_empty() || rest.contains(['/', '?', '#', '@', ' ']) { + return Err(format!( + "allowed_origins entry {o:?} must not contain a path, query, credentials or fragment" + )); + } + let host = rest.strip_prefix("*.").unwrap_or(rest); + let host = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host); + if host.is_empty() || host.contains('*') { + return Err(format!( + "allowed_origins entry {o:?}: wildcard is only allowed as a leading `*.`" + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -183,4 +232,37 @@ mod tests { r.allowed_origins = Some(vec!["https://app.example.com".into(), " ".into()]); assert!(r.validate().is_err()); } + + #[test] + fn ip_rate_limit_requires_both_halves() { + let mut r = base(); + r.ip_rate_limit_burst = Some(5); + assert!(r.validate().is_err()); + r.ip_rate_limit_rps = Some(1); + assert!(r.validate().is_ok()); + } + + #[test] + fn origin_patterns() { + for ok in [ + "https://app.example.com", + "http://localhost:3000", + "https://*.example.com", + "HTTPS://App.Example.com:8443", + ] { + assert!(validate_origin_pattern(ok).is_ok(), "{ok}"); + } + for bad in [ + "app.example.com", + "ftp://x.example.com", + "https://app.example.com/", + "https://app.example.com/path", + "https://user@app.example.com", + "https://*", + "https://a.*.example.com", + "https://*.", + ] { + assert!(validate_origin_pattern(bad).is_err(), "{bad}"); + } + } } From 2f7242fb852caffea262e1aaee3be324a35d6980 Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 15 Sep 2026 12:20:19 -0700 Subject: [PATCH 07/10] feat(api): set/remove/get_spending_rules endpoints wire the DB row to the on-chain flag Master-key-authed account-management endpoints close the loop the PR body listed as unwired: - POST /set_spending_rules -> PUT rules to lit-payments (/internal, service auth), then setSpendingRulesFlag(account, usage, true). Ownership is proven by the contract (NoAccountAccess revert) before any row is written for an already-flagged key, and by the flag tx otherwise. - POST /remove_spending_rules -> flag false, then delete row + usage counter. - GET /get_spending_rules -> rules + window spend + on_chain_flag (exposes a half-failed write for reconciliation). setSpendingRulesFlag/getSpendingRulesFlag are called via the scoped sol! interface (no binding regen needed). Both key caches are invalidated on write. lit-payments gains ServiceAuth PUT/DELETE /internal/spending-rules/. Co-Authored-By: Claude Fable 5.1 --- lit-api-server/src/accounts/mod.rs | 49 +++++ .../src/accounts/signable_contract.rs | 12 +- lit-api-server/src/core/account_management.rs | 193 +++++++++++++++++- .../core/v1/endpoints/account_management.rs | 85 +++++++- lit-api-server/src/core/v1/endpoints/mod.rs | 3 + lit-api-server/src/core/v1/models/request.rs | 49 +++++ lit-api-server/src/core/v1/models/response.rs | 29 +++ lit-payments/README.md | 7 + lit-payments/src/main.rs | 2 + lit-payments/src/spending/routes.rs | 45 +++- 10 files changed, 461 insertions(+), 13 deletions(-) diff --git a/lit-api-server/src/accounts/mod.rs b/lit-api-server/src/accounts/mod.rs index c7237144..189596d5 100644 --- a/lit-api-server/src/accounts/mod.rs +++ b/lit-api-server/src/accounts/mod.rs @@ -923,10 +923,59 @@ mod spending_view { uint256 apiKeyHash, uint256 cidHash ) external view returns (bool canExecute, bool hasSpendingRules); + function getSpendingRulesFlag(uint256 apiKeyHash) external view returns (bool); + function setSpendingRulesFlag( + uint256 accountApiKeyHash, + uint256 usageApiKeyHash, + bool hasSpendingRules + ) external; } } } +/// Read a usage key's on-chain `hasSpendingRules` flag (uncached; owner path). +pub async fn get_spending_rules_flag(usage_api_key_or_hash: &str) -> Result { + let (client, address) = crate::accounts::signable_contract::read_only_client_and_address()?; + let contract = spending_view::SpendingView::new(address, client); + let usage_hash = usage_api_key_to_hash(usage_api_key_or_hash); + Ok(contract.getSpendingRulesFlag(usage_hash).call().await?) +} + +/// Flip a usage key's on-chain `hasSpendingRules` flag +/// (AccountConfig.setSpendingRulesFlag). The master `api_key` must own the +/// account; the contract reverts with `NoAccountAccess` otherwise. Invalidates +/// the permission cache for both keys so the gateway's combined +/// execute+spending lookup refetches on the next call. +pub async fn set_spending_rules_flag( + signer_pool: Arc, + api_key: &str, + usage_api_key_or_hash: &str, + has_spending_rules: bool, +) -> Result { + let signer_handle = signer_pool.request().await?; + let client = signer_handle + .client + .ok_or(anyhow::anyhow!("No signer available"))?; + let lease = signer_handle.lease; + let address = crate::accounts::signable_contract::account_config_address()?; + let contract = spending_view::SpendingView::new(address, client.clone()); + + let account_api_key_hash = api_key_hash(api_key); + let usage_api_key_hash = usage_api_key_to_hash(usage_api_key_or_hash); + tracing::info!( + "Setting spending-rules flag: account_api_key_hash={:#x}, usage_api_key_hash={:#x}, flag={}", + account_api_key_hash, + usage_api_key_hash, + has_spending_rules + ); + + let function_call = + contract.setSpendingRulesFlag(account_api_key_hash, usage_api_key_hash, has_spending_rules); + let result = send_transaction(function_call, signer_pool, lease, client).await?; + blockchain_cache::invalidate_for_keys(api_key, usage_api_key_or_hash); + Ok(result) +} + async fn fetch_execute_and_spending( account_api_key_hash: U256, cid_hash_eth: U256, diff --git a/lit-api-server/src/accounts/signable_contract.rs b/lit-api-server/src/accounts/signable_contract.rs index fa738676..165e2399 100644 --- a/lit-api-server/src/accounts/signable_contract.rs +++ b/lit-api-server/src/accounts/signable_contract.rs @@ -152,13 +152,17 @@ pub(crate) fn get_read_only_client() -> Result { /// workspace alloy version directly; fold callers into the generated binding /// once it is regenerated on the canonical toolchain. pub(crate) fn read_only_client_and_address() -> Result<(SigningClient, Address)> { - let client = get_read_only_client()?; + Ok((get_read_only_client()?, account_config_address()?)) +} + +/// The configured AccountConfig diamond address. +pub(crate) fn account_config_address() -> Result
{ let node_config = GLOBAL_NODE_CONFIG .get() .ok_or_else(|| anyhow::anyhow!("Node configuration not found"))?; - let account_config_address = - Address::from_slice(&hex_to_bytes(&node_config.contract_address)?); - Ok((client, account_config_address)) + Ok(Address::from_slice(&hex_to_bytes( + &node_config.contract_address, + )?)) } pub(crate) async fn get_read_only_account_config_contract() -> Result { diff --git a/lit-api-server/src/core/account_management.rs b/lit-api-server/src/core/account_management.rs index 1be77dd3..c36ff216 100644 --- a/lit-api-server/src/core/account_management.rs +++ b/lit-api-server/src/core/account_management.rs @@ -3,27 +3,30 @@ use std::sync::Arc; use crate::accounts::chain_config::config_key_names; use crate::accounts::signer_pool::SignerPool; use crate::config::GLOBAL_NODE_CONFIG; +use crate::core::spending_rules::{RuleSet, SpendingRulesError, SpendingRulesState}; use crate::core::v1::helpers::api_status::ApiStatus; use crate::core::v1::models::request::{ AddActionRequest, AddActionToGroupRequest, AddGroupRequest, AddPkpToGroupRequest, AddUsageApiKeyRequest, AddUsageApiKeyWithSignatureRequest, ConvertToChainSecuredAccountRequest, CreateWalletWithSignatureRequest, DeleteActionRequest, DeleteWalletRequest, NewAccountRequest, RemoveActionFromGroupRequest, RemoveGroupRequest, RemovePkpFromGroupRequest, - RemoveUsageApiKeyRequest, UpdateActionMetadataRequest, UpdateGroupRequest, - UpdateUsageApiKeyMetadataRequest, UpdateUsageApiKeyRequest, + RemoveUsageApiKeyRequest, SetSpendingRulesRequest, UpdateActionMetadataRequest, + UpdateGroupRequest, UpdateUsageApiKeyMetadataRequest, UpdateUsageApiKeyRequest, + UsageKeySpendingRulesRequest, }; use crate::core::v1::models::response::{ AccountOpResponse, AddGroupResponse, AddUsageApiKeyResponse, AddUsageApiKeyWithSignatureResponse, ApiKeyItem, ChainConfigKeysResponse, CreateWalletResponse, CreateWalletWithSignatureResponse, ListMetadataItem, NewAccountResponse, - NodeChainConfigResponse, PrepareWalletResponse, WalletItem, + NodeChainConfigResponse, PrepareWalletResponse, SpendingRulesItem, SpendingRulesResponse, + WalletItem, }; use crate::dstack::v1::get_client_key; use crate::stripe::StripeState; use crate::utils::generate_unique_derivation_path; use crate::utils::parse_with_hash::{ hashed_cid_to_u256, hex_array_to_h160_array, hex_array_to_u256_array, ipfs_cid_to_u256, - is_precomputed_hash_shape, string_group_id_to_u256, + is_precomputed_hash_shape, string_group_id_to_u256, usage_api_key_to_hash, }; use crate::{accounts, dstack}; use alloy::primitives::{Address, U256}; @@ -973,6 +976,188 @@ pub async fn get_admin_api_payer() -> Result { Ok(bytes_to_0x_hex(signer.address().as_slice())) } +// ─── Spending rules (Lambda parity) ───────────────────────────────────────── + +fn map_spending_error(e: SpendingRulesError) -> ApiStatus { + match e { + SpendingRulesError::NotConfigured => ApiStatus { + status: rocket::http::Status::ServiceUnavailable, + message: e.to_string(), + }, + SpendingRulesError::Rejected(m) => ApiStatus::bad_request(anyhow::anyhow!(m.clone()), m), + SpendingRulesError::Upstream(_) => ApiStatus { + status: rocket::http::Status::BadGateway, + message: e.to_string(), + }, + } +} + +fn usage_key_hash_hex(usage_api_key_or_hash: &str) -> String { + let h = usage_api_key_to_hash(usage_api_key_or_hash); + format!("0x{:0>64}", format!("{h:x}")) +} + +fn rules_item(r: RuleSet) -> SpendingRulesItem { + SpendingRulesItem { + spend_cap_cents: r.spend_cap_cents, + spend_window_seconds: r.spend_window_seconds, + rate_limit_rps: r.rate_limit_rps, + rate_limit_burst: r.rate_limit_burst, + max_concurrency: r.max_concurrency, + ip_rate_limit_rps: r.ip_rate_limit_rps, + ip_rate_limit_burst: r.ip_rate_limit_burst, + allowed_origins: r.allowed_origins, + enabled: r.enabled, + } +} + +/// Store a usage key's spending rules in lit-payments, then set the on-chain +/// `hasSpendingRules` gate so the gateway starts enforcing them. +/// +/// Order matters: the row is written first so that the moment the flag flips, +/// the gateway's fetch finds rules rather than a 404 (which it treats as +/// "no rules", i.e. fails open). If the flag tx fails after the row is stored, +/// the rules are inert but harmless; `get_spending_rules` exposes the mismatch +/// via `on_chain_flag` and re-running this call repairs it. +pub async fn set_spending_rules( + signer_pool: Arc, + spending: &SpendingRulesState, + api_key: &str, + req: Json, +) -> Result { + let req = req.into_inner(); + let usage_key = req.usage_api_key.trim().to_string(); + if usage_key.is_empty() { + return Err(ApiStatus::bad_request( + anyhow::anyhow!("usage_api_key is required"), + "usage_api_key is required", + )); + } + let rules = RuleSet { + spend_cap_cents: req.spend_cap_cents, + spend_window_seconds: req.spend_window_seconds, + rate_limit_rps: req.rate_limit_rps, + rate_limit_burst: req.rate_limit_burst, + max_concurrency: req.max_concurrency, + ip_rate_limit_rps: req.ip_rate_limit_rps, + ip_rate_limit_burst: req.ip_rate_limit_burst, + allowed_origins: req.allowed_origins, + enabled: req.enabled, + }; + + // Fail fast (and before any on-chain write) if this node can't reach the store. + if !spending.is_configured() { + return Err(map_spending_error(SpendingRulesError::NotConfigured)); + } + + // Authorization is the on-chain write: `setSpendingRulesFlag` reverts with + // NoAccountAccess unless `api_key` owns the account. Do the (cheap, + // simulated) chain write first so a foreign usage key never gets a row. + let already_flagged = accounts::get_spending_rules_flag(&usage_key) + .await + .map_err(|e| map_contract_error(e, "get_spending_rules_flag failed"))?; + if already_flagged { + // Still prove ownership before touching the row: an unflagged write + // below would do it implicitly; here nothing else would. + accounts::set_spending_rules_flag(signer_pool.clone(), api_key, &usage_key, true) + .await + .map_err(|e| map_contract_error(e, "set_spending_rules_flag failed"))?; + } + + let stored = spending + .set_rules(&usage_key, &rules) + .await + .map_err(map_spending_error)?; + + if !already_flagged { + accounts::set_spending_rules_flag(signer_pool, api_key, &usage_key, true) + .await + .map_err(|e| map_contract_error(e, "set_spending_rules_flag failed"))?; + } + + Ok(SpendingRulesResponse { + usage_api_key_hash: usage_key_hash_hex(&usage_key), + rules: Some(rules_item(stored)), + spent_cents_in_window: None, + on_chain_flag: true, + }) +} + +/// Clear the on-chain gate first (so the gateway stops consulting rules), then +/// delete the row + usage counter in lit-payments. +pub async fn remove_spending_rules( + signer_pool: Arc, + spending: &SpendingRulesState, + api_key: &str, + req: Json, +) -> Result { + let usage_key = req.usage_api_key.trim().to_string(); + if usage_key.is_empty() { + return Err(ApiStatus::bad_request( + anyhow::anyhow!("usage_api_key is required"), + "usage_api_key is required", + )); + } + if !spending.is_configured() { + return Err(map_spending_error(SpendingRulesError::NotConfigured)); + } + // Ownership check + gate off in one tx (reverts for a foreign key). + accounts::set_spending_rules_flag(signer_pool, api_key, &usage_key, false) + .await + .map_err(|e| map_contract_error(e, "set_spending_rules_flag failed"))?; + spending + .delete_rules(&usage_key) + .await + .map_err(map_spending_error)?; + Ok(SpendingRulesResponse { + usage_api_key_hash: usage_key_hash_hex(&usage_key), + rules: None, + spent_cents_in_window: None, + on_chain_flag: false, + }) +} + +/// Read a usage key's stored rules + current window spend + on-chain flag. +/// Ownership is checked by confirming the usage key is listed under the caller's +/// account (a read-only chain call; no tx). +pub async fn get_spending_rules( + spending: &SpendingRulesState, + api_key: &str, + usage_api_key: &str, +) -> Result { + let usage_key = usage_api_key.trim(); + if usage_key.is_empty() { + return Err(ApiStatus::bad_request( + anyhow::anyhow!("usage_api_key is required"), + "usage_api_key is required", + )); + } + let usage_hash = usage_api_key_to_hash(usage_key); + let owned = accounts::list_api_keys(api_key, U256::ZERO, U256::from(1000u64)) + .await? + .iter() + .any(|k| k.apiKeyHash == usage_hash); + if !owned { + return Err(ApiStatus::forbidden( + "usage_api_key does not belong to this account", + )); + } + let on_chain_flag = accounts::get_spending_rules_flag(usage_key).await?; + let stored = spending + .get_rules(usage_key) + .await + .map_err(map_spending_error)?; + Ok(SpendingRulesResponse { + usage_api_key_hash: usage_key_hash_hex(usage_key), + spent_cents_in_window: stored + .as_ref() + .and_then(|s| s.usage.as_ref()) + .map(|u| u.spent_cents), + rules: stored.map(|s| rules_item(s.rules)), + on_chain_flag, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/lit-api-server/src/core/v1/endpoints/account_management.rs b/lit-api-server/src/core/v1/endpoints/account_management.rs index 04afb265..bd99522a 100644 --- a/lit-api-server/src/core/v1/endpoints/account_management.rs +++ b/lit-api-server/src/core/v1/endpoints/account_management.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use crate::accounts::signer_pool::SignerPool; use crate::core::account_management; +use crate::core::spending_rules::SpendingRulesState; use crate::core::v1::guards::apikey::ApiKey; use crate::core::v1::guards::billing::BilledManagementApiKey; use crate::core::v1::guards::cpu_overload::CpuAvailable; @@ -13,14 +14,15 @@ use crate::core::v1::models::request::{ AddUsageApiKeyRequest, AddUsageApiKeyWithSignatureRequest, ConvertToChainSecuredAccountRequest, CreateWalletWithSignatureRequest, DeleteActionRequest, DeleteWalletRequest, NewAccountRequest, RemoveActionFromGroupRequest, RemoveGroupRequest, RemovePkpFromGroupRequest, - RemoveUsageApiKeyRequest, UpdateActionMetadataRequest, UpdateGroupRequest, - UpdateUsageApiKeyMetadataRequest, UpdateUsageApiKeyRequest, + RemoveUsageApiKeyRequest, SetSpendingRulesRequest, UpdateActionMetadataRequest, + UpdateGroupRequest, UpdateUsageApiKeyMetadataRequest, UpdateUsageApiKeyRequest, + UsageKeySpendingRulesRequest, }; use crate::core::v1::models::response::{ AccountOpResponse, AddGroupResponse, AddUsageApiKeyResponse, AddUsageApiKeyWithSignatureResponse, ApiKeyItem, ChainConfigKeysResponse, CreateWalletResponse, CreateWalletWithSignatureResponse, ListMetadataItem, NewAccountResponse, - NodeChainConfigResponse, PrepareWalletResponse, WalletItem, + NodeChainConfigResponse, PrepareWalletResponse, SpendingRulesResponse, WalletItem, }; use crate::stripe::StripeState; use rocket::State; @@ -589,3 +591,80 @@ pub(super) async fn update_usage_api_key_metadata( .into(), } } + +/// Set (create or replace) the spending rules for one of your usage API keys and +/// turn on its on-chain `hasSpendingRules` gate. Use this to make a usage key +/// safe to embed in a frontend: a rolling spend cap (402 when reached), per-key +/// and per-client-IP rate limits and a concurrency cap (429), and a browser +/// origin allowlist (403). Keys without rules pay no extra latency. +/// +/// Requires the account's master API key. Returns 503 if this node is not +/// connected to the spending-rules store, 400 if the rules are invalid, and 403 +/// if the usage key does not belong to your account. +#[openapi(tag = "Account Management")] +#[post("/set_spending_rules", format = "json", data = "")] +pub(super) async fn set_spending_rules( + signer_pool: &State>, + spending: &State, + api_key: BilledManagementApiKey, + req: Json, +) -> OpenApiResponse { + OpenApiResponse { + response: ApiResult( + account_management::set_spending_rules( + signer_pool.inner().clone(), + spending.inner(), + api_key.0.as_str(), + req, + ) + .await, + ) + .into(), + } +} + +/// Remove the spending rules from one of your usage API keys and clear its +/// on-chain gate, returning it to unrestricted (account-level) limits. +#[openapi(tag = "Account Management")] +#[post("/remove_spending_rules", format = "json", data = "")] +pub(super) async fn remove_spending_rules( + signer_pool: &State>, + spending: &State, + api_key: BilledManagementApiKey, + req: Json, +) -> OpenApiResponse { + OpenApiResponse { + response: ApiResult( + account_management::remove_spending_rules( + signer_pool.inner().clone(), + spending.inner(), + api_key.0.as_str(), + req, + ) + .await, + ) + .into(), + } +} + +/// Read the spending rules, current-window spend and on-chain gate state for +/// one of your usage API keys. `usage_api_key` may be the raw key or its hash. +#[openapi(tag = "Account Management")] +#[get("/get_spending_rules?")] +pub(super) async fn get_spending_rules( + spending: &State, + api_key: ApiKey, + usage_api_key: &str, +) -> OpenApiResponse { + OpenApiResponse { + response: ApiResult( + account_management::get_spending_rules( + spending.inner(), + api_key.0.as_str(), + usage_api_key, + ) + .await, + ) + .into(), + } +} diff --git a/lit-api-server/src/core/v1/endpoints/mod.rs b/lit-api-server/src/core/v1/endpoints/mod.rs index ab72c5f3..de3661e8 100644 --- a/lit-api-server/src/core/v1/endpoints/mod.rs +++ b/lit-api-server/src/core/v1/endpoints/mod.rs @@ -44,6 +44,9 @@ pub fn routes_with_spec() -> (Vec, OpenApi) { remove_action_from_group, update_action_metadata, update_usage_api_key_metadata, + set_spending_rules, + remove_spending_rules, + get_spending_rules, list_groups, list_wallets, list_wallets_in_group, diff --git a/lit-api-server/src/core/v1/models/request.rs b/lit-api-server/src/core/v1/models/request.rs index abb12aef..74029a61 100644 --- a/lit-api-server/src/core/v1/models/request.rs +++ b/lit-api-server/src/core/v1/models/request.rs @@ -40,6 +40,55 @@ pub struct AddGroupRequest { pub cid_hashes_permitted: Vec, } +/// Request for set_spending_rules: make a usage key safe to embed in a frontend +/// by bounding its blast radius. Omitted limits mean "no limit"; paired fields +/// must be supplied together. Master API key via header. +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct SetSpendingRulesRequest { + /// The usage API key (raw key or its 0x-prefixed 32-byte hash as returned + /// by list_api_keys) the rules apply to. Must belong to the calling account. + pub usage_api_key: String, + /// Rolling spend cap in cents; pair with `spend_window_seconds`. 402 once reached. + #[serde(default)] + pub spend_cap_cents: Option, + /// Length of the rolling spend window in seconds; pair with `spend_cap_cents`. + #[serde(default)] + pub spend_window_seconds: Option, + /// Sustained requests/second for the key across all callers; pair with `rate_limit_burst`. + #[serde(default)] + pub rate_limit_rps: Option, + /// Burst allowance for the per-key limit; pair with `rate_limit_rps`. + #[serde(default)] + pub rate_limit_burst: Option, + /// Maximum simultaneously-executing actions for the key (429 above it). + #[serde(default)] + pub max_concurrency: Option, + /// Sustained requests/second per client IP; pair with `ip_rate_limit_burst`. + #[serde(default)] + pub ip_rate_limit_rps: Option, + /// Burst allowance for the per-IP limit; pair with `ip_rate_limit_rps`. + #[serde(default)] + pub ip_rate_limit_burst: Option, + /// Browser origins (`scheme://host[:port]`, `*.` host prefix allowed) permitted + /// to use the key. When set, requests without a matching `Origin` header get 403. + #[serde(default)] + pub allowed_origins: Option>, + /// Set false to keep the rules stored but not enforced. Defaults to true. + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + +/// Request for remove_spending_rules / get_spending_rules. Master API key via header. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct UsageKeySpendingRulesRequest { + /// The usage API key (raw key or its 0x-prefixed 32-byte hash). + pub usage_api_key: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct AddActionRequest { /// IPFS CID for the action (keccak256-hashed on server). diff --git a/lit-api-server/src/core/v1/models/response.rs b/lit-api-server/src/core/v1/models/response.rs index f2a58856..77f0ca5b 100644 --- a/lit-api-server/src/core/v1/models/response.rs +++ b/lit-api-server/src/core/v1/models/response.rs @@ -94,6 +94,35 @@ pub struct AccountOpResponse { pub success: bool, } +/// The stored spending rules for a usage key (see set_spending_rules). +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct SpendingRulesItem { + pub spend_cap_cents: Option, + pub spend_window_seconds: Option, + pub rate_limit_rps: Option, + pub rate_limit_burst: Option, + pub max_concurrency: Option, + pub ip_rate_limit_rps: Option, + pub ip_rate_limit_burst: Option, + pub allowed_origins: Option>, + pub enabled: bool, +} + +/// Response for set_spending_rules / get_spending_rules. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct SpendingRulesResponse { + /// The usage key's 0x-prefixed 32-byte hash the rules are stored under. + pub usage_api_key_hash: String, + /// Stored rules, or null when the key has none. + pub rules: Option, + /// Cents spent in the current rolling window (null when no cap / no usage yet). + pub spent_cents_in_window: Option, + /// Whether the on-chain `hasSpendingRules` gate is set for this key. Should + /// equal `rules.is_some()`; a mismatch means a previous write half-failed — + /// re-run set_spending_rules or remove_spending_rules to reconcile. + pub on_chain_flag: bool, +} + /// Response for add_group, includes the on-chain group ID. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct AddGroupResponse { diff --git a/lit-payments/README.md b/lit-payments/README.md index 61599c5e..50e113c4 100644 --- a/lit-payments/README.md +++ b/lit-payments/README.md @@ -200,6 +200,13 @@ LIT_ACCOUNTS_CONTRACT_ADDRESS=0x... # same value as lit-api-server NodeConfig.t # LIT_API_SERVER_BASE_URL=http://localhost:8000 # LIT_INTERNAL_SHARED_SECRET=$(openssl rand -base64 32) +# Spending rules (Lambda parity) — the /internal/spending-* endpoints the +# gateway calls are bearer-token authed. They accept INTERNAL_SERVICE_TOKEN, +# falling back to LIT_INTERNAL_SHARED_SECRET, so no extra secret is needed. +# Disabled (503) if neither is set. On the lit-api-server side set +# LIT_PAYMENTS_INTERNAL_URL to this service's base URL (same token). +# INTERNAL_SERVICE_TOKEN= + # Optional — gas funder (see "Gas funder" section below). Off entirely # unless GAS_FUNDER_PRIVATE_KEY is set. Leave GAS_FUNDER_ENABLED unset to # run in OBSERVE mode (alerts only, no on-chain sends). diff --git a/lit-payments/src/main.rs b/lit-payments/src/main.rs index e21d935b..855739c5 100644 --- a/lit-payments/src/main.rs +++ b/lit-payments/src/main.rs @@ -127,6 +127,8 @@ async fn rocket() -> _ { spending_routes::list_rules, spending_routes::delete_rules, spending_routes::internal_get_rules, + spending_routes::internal_put_rules, + spending_routes::internal_delete_rules, spending_routes::internal_charge, billing_routes::setup_intent::setup_intent, billing_routes::auto_topup_config::get_auto_topup_config, diff --git a/lit-payments/src/spending/routes.rs b/lit-payments/src/spending/routes.rs index b56d1103..4d7c2cca 100644 --- a/lit-payments/src/spending/routes.rs +++ b/lit-payments/src/spending/routes.rs @@ -1,7 +1,10 @@ //! Spending-rules HTTP routes. //! //! Operator-authed CRUD under `/api/spending-rules` (browser admin UI) and -//! `ServiceAuth`-authed endpoints under `/internal` (the gateway). +//! `ServiceAuth`-authed endpoints under `/internal` (the gateway: read rules, +//! record spend, and write/clear rules on behalf of an account owner who +//! called lit-api-server's `/set_spending_rules` — lit-api-server owns the +//! on-chain `hasSpendingRules` flag flip that goes with each write). use rocket::http::Status; use rocket::serde::json::Json; @@ -82,7 +85,9 @@ pub async fn list_rules( limit: Option, pool: &State, ) -> ApiResult { - let limit = limit.unwrap_or(DEFAULT_RULES_LIMIT).clamp(1, MAX_RULES_LIMIT); + let limit = limit + .unwrap_or(DEFAULT_RULES_LIMIT) + .clamp(1, MAX_RULES_LIMIT); let rules = db::list_rules(pool, limit).await.map_err(server_err)?; Ok(Json(RulesListResponse { rules })) } @@ -118,6 +123,42 @@ pub async fn internal_get_rules( Ok(Json(RulesWithUsage { rules, usage })) } +/// `PUT /internal/spending-rules/` — create or replace a key's rules on +/// behalf of the account owner. lit-api-server authenticates the owner (master +/// API key) and flips the on-chain flag; this just stores the row. +#[put( + "/internal/spending-rules/", + format = "json", + data = "" +)] +pub async fn internal_put_rules( + _svc: ServiceAuth, + api_key_hash: &str, + req: Json, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let req = req.into_inner(); + req.validate().map_err(|e| err(Status::BadRequest, e))?; + let rules = db::upsert_rules(pool, &hash, &req) + .await + .map_err(server_err)?; + Ok(Json(rules)) +} + +/// `DELETE /internal/spending-rules/` — clear a key's rules + usage +/// counter on behalf of the account owner (see `internal_put_rules`). +#[delete("/internal/spending-rules/")] +pub async fn internal_delete_rules( + _svc: ServiceAuth, + api_key_hash: &str, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let deleted = db::delete_rules(pool, &hash).await.map_err(server_err)?; + Ok(Json(DeleteResponse { deleted })) +} + /// `POST /internal/spending-usage//charge` — add to the rolling spend /// counter (resetting the window if elapsed). Called by the gateway off the /// response path; best-effort. From 9ef32ca0d597e19787ee544bca9c1d1ea01e2f7f Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 15 Sep 2026 12:25:32 -0700 Subject: [PATCH 08/10] chore(contracts): regenerate AccountConfig bindings; use them for the spending-rules calls CI's contract-bindings check requires the generated binding to match the facets, so regenerate it (forge 1.5.1, make generate). With the new functions in the binding, drop the scoped sol! interface and the address helper and call canExecuteActionWithSpendingRules / getSpendingRulesFlag / setSpendingRulesFlag through the regular AccountConfig instance like every other call. Co-Authored-By: Claude Fable 5.1 --- .../src/accounts/contracts/AccountConfig.json | 96 ++ .../contracts/account_config_contract.rs | 903 +++++++++++++++++- lit-api-server/src/accounts/mod.rs | 58 +- .../src/accounts/signable_contract.rs | 19 - 4 files changed, 997 insertions(+), 79 deletions(-) diff --git a/lit-api-server/src/accounts/contracts/AccountConfig.json b/lit-api-server/src/accounts/contracts/AccountConfig.json index f5a18d8a..d8b306b8 100644 --- a/lit-api-server/src/accounts/contracts/AccountConfig.json +++ b/lit-api-server/src/accounts/contracts/AccountConfig.json @@ -521,6 +521,31 @@ "name": "PkpRemovedFromGroup", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "accountApiKeyHash", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "usageApiKeyHash", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "hasSpendingRules", + "type": "bool" + } + ], + "name": "SpendingRulesFlagSet", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -972,6 +997,29 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "accountApiKeyHash", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "usageApiKeyHash", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "hasSpendingRules", + "type": "bool" + } + ], + "name": "setSpendingRulesFlag", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1386,6 +1434,35 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "apiKeyHash", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "cidHash", + "type": "uint256" + } + ], + "name": "canExecuteActionWithSpendingRules", + "outputs": [ + { + "internalType": "bool", + "name": "canExecute", + "type": "bool" + }, + { + "internalType": "bool", + "name": "hasSpendingRules", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -1514,6 +1591,25 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "apiKeyHash", + "type": "uint256" + } + ], + "name": "getSpendingRulesFlag", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/lit-api-server/src/accounts/contracts/account_config_contract.rs b/lit-api-server/src/accounts/contracts/account_config_contract.rs index d55f1cef..1160d5fa 100644 --- a/lit-api-server/src/accounts/contracts/account_config_contract.rs +++ b/lit-api-server/src/accounts/contracts/account_config_contract.rs @@ -1649,6 +1649,7 @@ interface AccountConfig { event RebalanceAmountUpdated(uint256 newRebalanceAmount); event RequestedApiPayerCountUpdated(uint256 newCount); event ServerTriggered(uint256 value, address indexed sender); + event SpendingRulesFlagSet(uint256 indexed accountApiKeyHash, uint256 indexed usageApiKeyHash, bool hasSpendingRules); event UsageApiKeyRemoved(uint256 indexed accountApiKeyHash, uint256 indexed usageApiKeyHash); event UsageApiKeySet(uint256 indexed accountApiKeyHash, uint256 indexed usageApiKeyHash); event WalletDerivationRegistered(uint256 indexed apiKeyHash, address indexed pkpId, uint256 derivationPath); @@ -1669,6 +1670,7 @@ interface AccountConfig { function canExecuteAction(uint256 apiKeyHash, uint256 cidHash) external view returns (bool); function canExecuteActionAndUseWallet(uint256 apiKeyHash, uint256 cidHash, address walletAddress) external view returns (bool canExecute, bool canUseWallet); function canExecuteActionFast(uint256 apiKeyHash, uint256 cidHash) external view returns (bool); + function canExecuteActionWithSpendingRules(uint256 apiKeyHash, uint256 cidHash) external view returns (bool canExecute, bool hasSpendingRules); function canUseWalletInAction(uint256 apiKeyHash, uint256 cidHash, address walletAddress) external view returns (bool); function canUseWalletInActionFast(uint256 apiKeyHash, uint256 cidHash, address walletAddress) external view returns (bool); function configOperator() external view returns (address); @@ -1679,6 +1681,7 @@ interface AccountConfig { function getBillingWalletAddress(uint256 apiKeyHash) external view returns (address); function getPkpOwnerMaster(address pkpId) external view returns (uint256); function getPricing(uint256 pricingItemId) external view returns (uint256); + function getSpendingRulesFlag(uint256 apiKeyHash) external view returns (bool); function getWalletDerivation(uint256 apiKeyHash, address walletAddress) external view returns (uint256); function groupIdsForAction(uint256 apiKeyHash, uint256 cidHash) external view returns (uint256[] memory); function groupIdsForActionAndWallet(uint256 apiKeyHash, uint256 cidHash, address walletAddress) external view returns (uint256[] memory); @@ -1716,6 +1719,7 @@ interface AccountConfig { function setPricingOperator(address newPricingOperator) external; function setRebalanceAmount(uint256 newRebalanceAmount) external; function setRequestedApiPayerCount(uint256 newRequestedApiPayerCount) external; + function setSpendingRulesFlag(uint256 accountApiKeyHash, uint256 usageApiKeyHash, bool hasSpendingRules) external; function setUsageApiKey(uint256 accountApiKeyHash, uint256 usageApiKeyHash, uint256 expiration, uint256 balance, string memory name, string memory description, bool createGroups, bool deleteGroups, bool createPKPs, uint256[] memory manageIPFSIdsInGroups, uint256[] memory addPkpToGroups, uint256[] memory removePkpFromGroups, uint256[] memory executeInGroups) external; function transferChainSecuredAccountOwnership(uint256 apiKeyHash, address newAdminWalletAddress) external; function updateActionMetadata(uint256 accountApiKeyHash, uint256 actionHash, uint256 groupId, string memory name, string memory description) external; @@ -2055,6 +2059,35 @@ interface AccountConfig { ], "stateMutability": "view" }, + { + "type": "function", + "name": "canExecuteActionWithSpendingRules", + "inputs": [ + { + "name": "apiKeyHash", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cidHash", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "canExecute", + "type": "bool", + "internalType": "bool" + }, + { + "name": "hasSpendingRules", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "canUseWalletInAction", @@ -2256,6 +2289,25 @@ interface AccountConfig { ], "stateMutability": "view" }, + { + "type": "function", + "name": "getSpendingRulesFlag", + "inputs": [ + { + "name": "apiKeyHash", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getWalletDerivation", @@ -3220,6 +3272,29 @@ interface AccountConfig { "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "setSpendingRulesFlag", + "inputs": [ + { + "name": "accountApiKeyHash", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "usageApiKeyHash", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "hasSpendingRules", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "setUsageApiKey", @@ -3875,6 +3950,31 @@ interface AccountConfig { ], "anonymous": false }, + { + "type": "event", + "name": "SpendingRulesFlagSet", + "inputs": [ + { + "name": "accountApiKeyHash", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "usageApiKeyHash", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "hasSpendingRules", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, { "type": "event", "name": "UsageApiKeyRemoved", @@ -8520,6 +8620,127 @@ pub mod AccountConfig { } }; #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] + /*Event with signature `SpendingRulesFlagSet(uint256,uint256,bool)` and selector `0x0122856f3a0558721b304826e38d25763a158254a1cd76c6bc2c3e6299309971`. + ```solidity + event SpendingRulesFlagSet(uint256 indexed accountApiKeyHash, uint256 indexed usageApiKeyHash, bool hasSpendingRules); + ```*/ + #[allow( + non_camel_case_types, + non_snake_case, + clippy::pub_underscore_fields, + clippy::style + )] + #[derive(Clone)] + pub struct SpendingRulesFlagSet { + #[allow(missing_docs)] + pub accountApiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + #[allow(missing_docs)] + pub usageApiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + #[allow(missing_docs)] + pub hasSpendingRules: bool, + } + #[allow( + non_camel_case_types, + non_snake_case, + clippy::pub_underscore_fields, + clippy::style + )] + const _: () = { + use alloy::sol_types as alloy_sol_types; + #[automatically_derived] + impl alloy_sol_types::SolEvent for SpendingRulesFlagSet { + type DataTuple<'a> = (alloy::sol_types::sol_data::Bool,); + type DataToken<'a> = as alloy_sol_types::SolType>::Token<'a>; + type TopicList = ( + alloy_sol_types::sol_data::FixedBytes<32>, + alloy::sol_types::sol_data::Uint<256>, + alloy::sol_types::sol_data::Uint<256>, + ); + const SIGNATURE: &'static str = "SpendingRulesFlagSet(uint256,uint256,bool)"; + const SIGNATURE_HASH: alloy_sol_types::private::B256 = + alloy_sol_types::private::B256::new([ + 1u8, 34u8, 133u8, 111u8, 58u8, 5u8, 88u8, 114u8, 27u8, 48u8, 72u8, 38u8, 227u8, + 141u8, 37u8, 118u8, 58u8, 21u8, 130u8, 84u8, 161u8, 205u8, 118u8, 198u8, 188u8, + 44u8, 62u8, 98u8, 153u8, 48u8, 153u8, 113u8, + ]); + const ANONYMOUS: bool = false; + #[allow(unused_variables)] + #[inline] + fn new( + topics: ::RustType, + data: as alloy_sol_types::SolType>::RustType, + ) -> Self { + Self { + accountApiKeyHash: topics.1, + usageApiKeyHash: topics.2, + hasSpendingRules: data.0, + } + } + #[inline] + fn check_signature( + topics: &::RustType, + ) -> alloy_sol_types::Result<()> { + if topics.0 != Self::SIGNATURE_HASH { + return Err(alloy_sol_types::Error::invalid_event_signature_hash( + Self::SIGNATURE, + topics.0, + Self::SIGNATURE_HASH, + )); + } + Ok(()) + } + #[inline] + fn tokenize_body(&self) -> Self::DataToken<'_> { + ( + ::tokenize( + &self.hasSpendingRules, + ), + ) + } + #[inline] + fn topics(&self) -> ::RustType { + ( + Self::SIGNATURE_HASH.into(), + self.accountApiKeyHash.clone(), + self.usageApiKeyHash.clone(), + ) + } + #[inline] + fn encode_topics_raw( + &self, + out: &mut [alloy_sol_types::abi::token::WordToken], + ) -> alloy_sol_types::Result<()> { + if out.len() < ::COUNT { + return Err(alloy_sol_types::Error::Overrun); + } + out[0usize] = alloy_sol_types::abi::token::WordToken(Self::SIGNATURE_HASH); + out[1usize] = as alloy_sol_types::EventTopic>::encode_topic(&self.accountApiKeyHash); + out[2usize] = as alloy_sol_types::EventTopic>::encode_topic(&self.usageApiKeyHash); + Ok(()) + } + } + #[automatically_derived] + impl alloy_sol_types::private::IntoLogData for SpendingRulesFlagSet { + fn to_log_data(&self) -> alloy_sol_types::private::LogData { + From::from(self) + } + fn into_log_data(self) -> alloy_sol_types::private::LogData { + From::from(&self) + } + } + #[automatically_derived] + impl From<&SpendingRulesFlagSet> for alloy_sol_types::private::LogData { + #[inline] + fn from(this: &SpendingRulesFlagSet) -> alloy_sol_types::private::LogData { + alloy_sol_types::SolEvent::encode_log_data(this) + } + } + }; + #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] /*Event with signature `UsageApiKeyRemoved(uint256,uint256)` and selector `0xde3420c9ebe0c0c3f0c0d1d25c55a1b97f758f2cf48e0c8f2b287f5536de1c80`. ```solidity event UsageApiKeyRemoved(uint256 indexed accountApiKeyHash, uint256 indexed usageApiKeyHash); @@ -11253,6 +11474,177 @@ pub mod AccountConfig { } }; #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] + /*Function with signature `canExecuteActionWithSpendingRules(uint256,uint256)` and selector `0x92f09a52`. + ```solidity + function canExecuteActionWithSpendingRules(uint256 apiKeyHash, uint256 cidHash) external view returns (bool canExecute, bool hasSpendingRules); + ```*/ + #[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)] + #[derive(Clone)] + pub struct canExecuteActionWithSpendingRulesCall { + #[allow(missing_docs)] + pub apiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + #[allow(missing_docs)] + pub cidHash: alloy::sol_types::private::primitives::aliases::U256, + } + #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] + //Container type for the return parameters of the [`canExecuteActionWithSpendingRules(uint256,uint256)`](canExecuteActionWithSpendingRulesCall) function. + #[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)] + #[derive(Clone)] + pub struct canExecuteActionWithSpendingRulesReturn { + #[allow(missing_docs)] + pub canExecute: bool, + #[allow(missing_docs)] + pub hasSpendingRules: bool, + } + #[allow( + non_camel_case_types, + non_snake_case, + clippy::pub_underscore_fields, + clippy::style + )] + const _: () = { + use alloy::sol_types as alloy_sol_types; + { + #[doc(hidden)] + #[allow(dead_code)] + type UnderlyingSolTuple<'a> = ( + alloy::sol_types::sol_data::Uint<256>, + alloy::sol_types::sol_data::Uint<256>, + ); + #[doc(hidden)] + type UnderlyingRustTuple<'a> = ( + alloy::sol_types::private::primitives::aliases::U256, + alloy::sol_types::private::primitives::aliases::U256, + ); + #[cfg(test)] + #[allow(dead_code, unreachable_patterns)] + fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq) { + match _t { + alloy_sol_types::private::AssertTypeEq::< + ::RustType, + >(_) => {} + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From for UnderlyingRustTuple<'_> { + fn from(value: canExecuteActionWithSpendingRulesCall) -> Self { + (value.apiKeyHash, value.cidHash) + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From> for canExecuteActionWithSpendingRulesCall { + fn from(tuple: UnderlyingRustTuple<'_>) -> Self { + Self { + apiKeyHash: tuple.0, + cidHash: tuple.1, + } + } + } + } + { + #[doc(hidden)] + #[allow(dead_code)] + type UnderlyingSolTuple<'a> = ( + alloy::sol_types::sol_data::Bool, + alloy::sol_types::sol_data::Bool, + ); + #[doc(hidden)] + type UnderlyingRustTuple<'a> = (bool, bool); + #[cfg(test)] + #[allow(dead_code, unreachable_patterns)] + fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq) { + match _t { + alloy_sol_types::private::AssertTypeEq::< + ::RustType, + >(_) => {} + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From for UnderlyingRustTuple<'_> { + fn from(value: canExecuteActionWithSpendingRulesReturn) -> Self { + (value.canExecute, value.hasSpendingRules) + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From> for canExecuteActionWithSpendingRulesReturn { + fn from(tuple: UnderlyingRustTuple<'_>) -> Self { + Self { + canExecute: tuple.0, + hasSpendingRules: tuple.1, + } + } + } + } + impl canExecuteActionWithSpendingRulesReturn { + fn _tokenize( + &self, + ) -> ::ReturnToken<'_> + { + ( + ::tokenize( + &self.canExecute, + ), + ::tokenize( + &self.hasSpendingRules, + ), + ) + } + } + #[automatically_derived] + impl alloy_sol_types::SolCall for canExecuteActionWithSpendingRulesCall { + type Parameters<'a> = ( + alloy::sol_types::sol_data::Uint<256>, + alloy::sol_types::sol_data::Uint<256>, + ); + type Token<'a> = as alloy_sol_types::SolType>::Token<'a>; + type Return = canExecuteActionWithSpendingRulesReturn; + type ReturnTuple<'a> = ( + alloy::sol_types::sol_data::Bool, + alloy::sol_types::sol_data::Bool, + ); + type ReturnToken<'a> = as alloy_sol_types::SolType>::Token<'a>; + const SIGNATURE: &'static str = "canExecuteActionWithSpendingRules(uint256,uint256)"; + const SELECTOR: [u8; 4] = [146u8, 240u8, 154u8, 82u8]; + #[inline] + fn new<'a>( + tuple: as alloy_sol_types::SolType>::RustType, + ) -> Self { + tuple.into() + } + #[inline] + fn tokenize(&self) -> Self::Token<'_> { + ( + as alloy_sol_types::SolType>::tokenize( + &self.apiKeyHash, + ), + as alloy_sol_types::SolType>::tokenize( + &self.cidHash, + ), + ) + } + #[inline] + fn tokenize_returns(ret: &Self::Return) -> Self::ReturnToken<'_> { + canExecuteActionWithSpendingRulesReturn::_tokenize(ret) + } + #[inline] + fn abi_decode_returns(data: &[u8]) -> alloy_sol_types::Result { + as alloy_sol_types::SolType>::abi_decode_sequence(data) + .map(Into::into) + } + #[inline] + fn abi_decode_returns_validate(data: &[u8]) -> alloy_sol_types::Result { + as alloy_sol_types::SolType>::abi_decode_sequence_validate( + data, + ) + .map(Into::into) + } + } + }; + #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] /*Function with signature `canUseWalletInAction(uint256,uint256,address)` and selector `0x25284ac1`. ```solidity function canUseWalletInAction(uint256 apiKeyHash, uint256 cidHash, address walletAddress) external view returns (bool); @@ -12651,21 +13043,162 @@ pub mod AccountConfig { } #[automatically_derived] #[doc(hidden)] - impl ::core::convert::From> for getPricingReturn { + impl ::core::convert::From> for getPricingReturn { + fn from(tuple: UnderlyingRustTuple<'_>) -> Self { + Self { _0: tuple.0 } + } + } + } + #[automatically_derived] + impl alloy_sol_types::SolCall for getPricingCall { + type Parameters<'a> = (alloy::sol_types::sol_data::Uint<256>,); + type Token<'a> = as alloy_sol_types::SolType>::Token<'a>; + type Return = alloy::sol_types::private::primitives::aliases::U256; + type ReturnTuple<'a> = (alloy::sol_types::sol_data::Uint<256>,); + type ReturnToken<'a> = as alloy_sol_types::SolType>::Token<'a>; + const SIGNATURE: &'static str = "getPricing(uint256)"; + const SELECTOR: [u8; 4] = [193u8, 47u8, 26u8, 66u8]; + #[inline] + fn new<'a>( + tuple: as alloy_sol_types::SolType>::RustType, + ) -> Self { + tuple.into() + } + #[inline] + fn tokenize(&self) -> Self::Token<'_> { + ( + as alloy_sol_types::SolType>::tokenize( + &self.pricingItemId, + ), + ) + } + #[inline] + fn tokenize_returns(ret: &Self::Return) -> Self::ReturnToken<'_> { + ( + as alloy_sol_types::SolType>::tokenize( + ret, + ), + ) + } + #[inline] + fn abi_decode_returns(data: &[u8]) -> alloy_sol_types::Result { + as alloy_sol_types::SolType>::abi_decode_sequence(data).map( + |r| { + let r: getPricingReturn = r.into(); + r._0 + }, + ) + } + #[inline] + fn abi_decode_returns_validate(data: &[u8]) -> alloy_sol_types::Result { + as alloy_sol_types::SolType>::abi_decode_sequence_validate( + data, + ) + .map(|r| { + let r: getPricingReturn = r.into(); + r._0 + }) + } + } + }; + #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] + /*Function with signature `getSpendingRulesFlag(uint256)` and selector `0xadb95c8b`. + ```solidity + function getSpendingRulesFlag(uint256 apiKeyHash) external view returns (bool); + ```*/ + #[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)] + #[derive(Clone)] + pub struct getSpendingRulesFlagCall { + #[allow(missing_docs)] + pub apiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + } + #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] + //Container type for the return parameters of the [`getSpendingRulesFlag(uint256)`](getSpendingRulesFlagCall) function. + #[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)] + #[derive(Clone)] + pub struct getSpendingRulesFlagReturn { + #[allow(missing_docs)] + pub _0: bool, + } + #[allow( + non_camel_case_types, + non_snake_case, + clippy::pub_underscore_fields, + clippy::style + )] + const _: () = { + use alloy::sol_types as alloy_sol_types; + { + #[doc(hidden)] + #[allow(dead_code)] + type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::Uint<256>,); + #[doc(hidden)] + type UnderlyingRustTuple<'a> = (alloy::sol_types::private::primitives::aliases::U256,); + #[cfg(test)] + #[allow(dead_code, unreachable_patterns)] + fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq) { + match _t { + alloy_sol_types::private::AssertTypeEq::< + ::RustType, + >(_) => {} + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From for UnderlyingRustTuple<'_> { + fn from(value: getSpendingRulesFlagCall) -> Self { + (value.apiKeyHash,) + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From> for getSpendingRulesFlagCall { + fn from(tuple: UnderlyingRustTuple<'_>) -> Self { + Self { + apiKeyHash: tuple.0, + } + } + } + } + { + #[doc(hidden)] + #[allow(dead_code)] + type UnderlyingSolTuple<'a> = (alloy::sol_types::sol_data::Bool,); + #[doc(hidden)] + type UnderlyingRustTuple<'a> = (bool,); + #[cfg(test)] + #[allow(dead_code, unreachable_patterns)] + fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq) { + match _t { + alloy_sol_types::private::AssertTypeEq::< + ::RustType, + >(_) => {} + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From for UnderlyingRustTuple<'_> { + fn from(value: getSpendingRulesFlagReturn) -> Self { + (value._0,) + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From> for getSpendingRulesFlagReturn { fn from(tuple: UnderlyingRustTuple<'_>) -> Self { Self { _0: tuple.0 } } } } #[automatically_derived] - impl alloy_sol_types::SolCall for getPricingCall { + impl alloy_sol_types::SolCall for getSpendingRulesFlagCall { type Parameters<'a> = (alloy::sol_types::sol_data::Uint<256>,); type Token<'a> = as alloy_sol_types::SolType>::Token<'a>; - type Return = alloy::sol_types::private::primitives::aliases::U256; - type ReturnTuple<'a> = (alloy::sol_types::sol_data::Uint<256>,); + type Return = bool; + type ReturnTuple<'a> = (alloy::sol_types::sol_data::Bool,); type ReturnToken<'a> = as alloy_sol_types::SolType>::Token<'a>; - const SIGNATURE: &'static str = "getPricing(uint256)"; - const SELECTOR: [u8; 4] = [193u8, 47u8, 26u8, 66u8]; + const SIGNATURE: &'static str = "getSpendingRulesFlag(uint256)"; + const SELECTOR: [u8; 4] = [173u8, 185u8, 92u8, 139u8]; #[inline] fn new<'a>( tuple: as alloy_sol_types::SolType>::RustType, @@ -12676,23 +13209,19 @@ pub mod AccountConfig { fn tokenize(&self) -> Self::Token<'_> { ( as alloy_sol_types::SolType>::tokenize( - &self.pricingItemId, + &self.apiKeyHash, ), ) } #[inline] fn tokenize_returns(ret: &Self::Return) -> Self::ReturnToken<'_> { - ( - as alloy_sol_types::SolType>::tokenize( - ret, - ), - ) + (::tokenize(ret),) } #[inline] fn abi_decode_returns(data: &[u8]) -> alloy_sol_types::Result { as alloy_sol_types::SolType>::abi_decode_sequence(data).map( |r| { - let r: getPricingReturn = r.into(); + let r: getSpendingRulesFlagReturn = r.into(); r._0 }, ) @@ -12703,7 +13232,7 @@ pub mod AccountConfig { data, ) .map(|r| { - let r: getPricingReturn = r.into(); + let r: getSpendingRulesFlagReturn = r.into(); r._0 }) } @@ -18278,6 +18807,168 @@ pub mod AccountConfig { } }; #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] + /*Function with signature `setSpendingRulesFlag(uint256,uint256,bool)` and selector `0xb8520809`. + ```solidity + function setSpendingRulesFlag(uint256 accountApiKeyHash, uint256 usageApiKeyHash, bool hasSpendingRules) external; + ```*/ + #[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)] + #[derive(Clone)] + pub struct setSpendingRulesFlagCall { + #[allow(missing_docs)] + pub accountApiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + #[allow(missing_docs)] + pub usageApiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + #[allow(missing_docs)] + pub hasSpendingRules: bool, + } + //Container type for the return parameters of the [`setSpendingRulesFlag(uint256,uint256,bool)`](setSpendingRulesFlagCall) function. + #[allow(non_camel_case_types, non_snake_case, clippy::pub_underscore_fields)] + #[derive(Clone)] + pub struct setSpendingRulesFlagReturn {} + #[allow( + non_camel_case_types, + non_snake_case, + clippy::pub_underscore_fields, + clippy::style + )] + const _: () = { + use alloy::sol_types as alloy_sol_types; + { + #[doc(hidden)] + #[allow(dead_code)] + type UnderlyingSolTuple<'a> = ( + alloy::sol_types::sol_data::Uint<256>, + alloy::sol_types::sol_data::Uint<256>, + alloy::sol_types::sol_data::Bool, + ); + #[doc(hidden)] + type UnderlyingRustTuple<'a> = ( + alloy::sol_types::private::primitives::aliases::U256, + alloy::sol_types::private::primitives::aliases::U256, + bool, + ); + #[cfg(test)] + #[allow(dead_code, unreachable_patterns)] + fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq) { + match _t { + alloy_sol_types::private::AssertTypeEq::< + ::RustType, + >(_) => {} + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From for UnderlyingRustTuple<'_> { + fn from(value: setSpendingRulesFlagCall) -> Self { + ( + value.accountApiKeyHash, + value.usageApiKeyHash, + value.hasSpendingRules, + ) + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From> for setSpendingRulesFlagCall { + fn from(tuple: UnderlyingRustTuple<'_>) -> Self { + Self { + accountApiKeyHash: tuple.0, + usageApiKeyHash: tuple.1, + hasSpendingRules: tuple.2, + } + } + } + } + { + #[doc(hidden)] + #[allow(dead_code)] + type UnderlyingSolTuple<'a> = (); + #[doc(hidden)] + type UnderlyingRustTuple<'a> = (); + #[cfg(test)] + #[allow(dead_code, unreachable_patterns)] + fn _type_assertion(_t: alloy_sol_types::private::AssertTypeEq) { + match _t { + alloy_sol_types::private::AssertTypeEq::< + ::RustType, + >(_) => {} + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From for UnderlyingRustTuple<'_> { + fn from(value: setSpendingRulesFlagReturn) -> Self { + () + } + } + #[automatically_derived] + #[doc(hidden)] + impl ::core::convert::From> for setSpendingRulesFlagReturn { + fn from(tuple: UnderlyingRustTuple<'_>) -> Self { + Self {} + } + } + } + impl setSpendingRulesFlagReturn { + fn _tokenize( + &self, + ) -> ::ReturnToken<'_> + { + () + } + } + #[automatically_derived] + impl alloy_sol_types::SolCall for setSpendingRulesFlagCall { + type Parameters<'a> = ( + alloy::sol_types::sol_data::Uint<256>, + alloy::sol_types::sol_data::Uint<256>, + alloy::sol_types::sol_data::Bool, + ); + type Token<'a> = as alloy_sol_types::SolType>::Token<'a>; + type Return = setSpendingRulesFlagReturn; + type ReturnTuple<'a> = (); + type ReturnToken<'a> = as alloy_sol_types::SolType>::Token<'a>; + const SIGNATURE: &'static str = "setSpendingRulesFlag(uint256,uint256,bool)"; + const SELECTOR: [u8; 4] = [184u8, 82u8, 8u8, 9u8]; + #[inline] + fn new<'a>( + tuple: as alloy_sol_types::SolType>::RustType, + ) -> Self { + tuple.into() + } + #[inline] + fn tokenize(&self) -> Self::Token<'_> { + ( + as alloy_sol_types::SolType>::tokenize( + &self.accountApiKeyHash, + ), + as alloy_sol_types::SolType>::tokenize( + &self.usageApiKeyHash, + ), + ::tokenize( + &self.hasSpendingRules, + ), + ) + } + #[inline] + fn tokenize_returns(ret: &Self::Return) -> Self::ReturnToken<'_> { + setSpendingRulesFlagReturn::_tokenize(ret) + } + #[inline] + fn abi_decode_returns(data: &[u8]) -> alloy_sol_types::Result { + as alloy_sol_types::SolType>::abi_decode_sequence(data) + .map(Into::into) + } + #[inline] + fn abi_decode_returns_validate(data: &[u8]) -> alloy_sol_types::Result { + as alloy_sol_types::SolType>::abi_decode_sequence_validate( + data, + ) + .map(Into::into) + } + } + }; + #[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq, Eq, Hash)] /*Function with signature `setUsageApiKey(uint256,uint256,uint256,uint256,string,string,bool,bool,bool,uint256[],uint256[],uint256[],uint256[])` and selector `0x2da0cf16`. ```solidity function setUsageApiKey(uint256 accountApiKeyHash, uint256 usageApiKeyHash, uint256 expiration, uint256 balance, string memory name, string memory description, bool createGroups, bool deleteGroups, bool createPKPs, uint256[] memory manageIPFSIdsInGroups, uint256[] memory addPkpToGroups, uint256[] memory removePkpFromGroups, uint256[] memory executeInGroups) external; @@ -19457,6 +20148,8 @@ pub mod AccountConfig { #[allow(missing_docs)] canExecuteActionFast(canExecuteActionFastCall), #[allow(missing_docs)] + canExecuteActionWithSpendingRules(canExecuteActionWithSpendingRulesCall), + #[allow(missing_docs)] canUseWalletInAction(canUseWalletInActionCall), #[allow(missing_docs)] canUseWalletInActionFast(canUseWalletInActionFastCall), @@ -19477,6 +20170,8 @@ pub mod AccountConfig { #[allow(missing_docs)] getPricing(getPricingCall), #[allow(missing_docs)] + getSpendingRulesFlag(getSpendingRulesFlagCall), + #[allow(missing_docs)] getWalletDerivation(getWalletDerivationCall), #[allow(missing_docs)] groupIdsForAction(groupIdsForActionCall), @@ -19551,6 +20246,8 @@ pub mod AccountConfig { #[allow(missing_docs)] setRequestedApiPayerCount(setRequestedApiPayerCountCall), #[allow(missing_docs)] + setSpendingRulesFlag(setSpendingRulesFlagCall), + #[allow(missing_docs)] setUsageApiKey(setUsageApiKeyCall), #[allow(missing_docs)] transferChainSecuredAccountOwnership(transferChainSecuredAccountOwnershipCall), @@ -19613,16 +20310,19 @@ pub mod AccountConfig { [134u8, 68u8, 113u8, 67u8], [144u8, 34u8, 44u8, 173u8], [146u8, 20u8, 21u8, 82u8], + [146u8, 240u8, 154u8, 82u8], [147u8, 200u8, 188u8, 67u8], [155u8, 128u8, 254u8, 131u8], [159u8, 229u8, 25u8, 222u8], [166u8, 103u8, 102u8, 101u8], [166u8, 182u8, 182u8, 114u8], + [173u8, 185u8, 92u8, 139u8], [174u8, 140u8, 73u8, 165u8], [178u8, 2u8, 138u8, 151u8], [180u8, 53u8, 149u8, 193u8], [180u8, 155u8, 139u8, 137u8], [184u8, 3u8, 127u8, 254u8], + [184u8, 82u8, 8u8, 9u8], [192u8, 1u8, 188u8, 121u8], [193u8, 47u8, 26u8, 66u8], [193u8, 175u8, 248u8, 153u8], @@ -19684,16 +20384,19 @@ pub mod AccountConfig { ::core::stringify!(canUseWalletInActionFast), ::core::stringify!(getWalletDerivation), ::core::stringify!(registerWalletDerivation), + ::core::stringify!(canExecuteActionWithSpendingRules), ::core::stringify!(api_payers), ::core::stringify!(setNodeConfiguration), ::core::stringify!(canExecuteActionFast), ::core::stringify!(groupIdsForActionAndWallet), ::core::stringify!(updateActionMetadata), + ::core::stringify!(getSpendingRulesFlag), ::core::stringify!(setApiPayers), ::core::stringify!(canExecuteActionAndUseWallet), ::core::stringify!(getPkpOwnerMaster), ::core::stringify!(addAction), ::core::stringify!(apiPayerCount), + ::core::stringify!(setSpendingRulesFlag), ::core::stringify!(setAdminApiPayerAccount), ::core::stringify!(getPricing), ::core::stringify!(pricingAt), @@ -19755,16 +20458,19 @@ pub mod AccountConfig { ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, + ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, + ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, + ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, @@ -19805,7 +20511,7 @@ pub mod AccountConfig { impl alloy_sol_types::SolInterface for AccountConfigCalls { const NAME: &'static str = "AccountConfigCalls"; const MIN_DATA_LENGTH: usize = 0usize; - const COUNT: usize = 68usize; + const COUNT: usize = 71usize; #[inline] fn selector(&self) -> [u8; 4] { match self { @@ -19840,6 +20546,9 @@ pub mod AccountConfig { Self::canExecuteActionFast(_) => { ::SELECTOR } + Self::canExecuteActionWithSpendingRules(_) => { + ::SELECTOR + } Self::canUseWalletInAction(_) => { ::SELECTOR } @@ -19864,6 +20573,9 @@ pub mod AccountConfig { ::SELECTOR } Self::getPricing(_) => ::SELECTOR, + Self::getSpendingRulesFlag(_) => { + ::SELECTOR + } Self::getWalletDerivation(_) => { ::SELECTOR } @@ -19951,6 +20663,9 @@ pub mod AccountConfig { Self::setRequestedApiPayerCount(_) => { ::SELECTOR } + Self::setSpendingRulesFlag(_) => { + ::SELECTOR + } Self::setUsageApiKey(_) => { ::SELECTOR } @@ -20363,6 +21078,17 @@ pub mod AccountConfig { } registerWalletDerivation }, + { + fn canExecuteActionWithSpendingRules( + data: &[u8], + ) -> alloy_sol_types::Result { + ::abi_decode_raw( + data, + ) + .map(AccountConfigCalls::canExecuteActionWithSpendingRules) + } + canExecuteActionWithSpendingRules + }, { fn api_payers(data: &[u8]) -> alloy_sol_types::Result { ::abi_decode_raw(data) @@ -20408,6 +21134,15 @@ pub mod AccountConfig { } updateActionMetadata }, + { + fn getSpendingRulesFlag( + data: &[u8], + ) -> alloy_sol_types::Result { + ::abi_decode_raw(data) + .map(AccountConfigCalls::getSpendingRulesFlag) + } + getSpendingRulesFlag + }, { fn setApiPayers(data: &[u8]) -> alloy_sol_types::Result { ::abi_decode_raw(data) @@ -20449,6 +21184,15 @@ pub mod AccountConfig { } apiPayerCount }, + { + fn setSpendingRulesFlag( + data: &[u8], + ) -> alloy_sol_types::Result { + ::abi_decode_raw(data) + .map(AccountConfigCalls::setSpendingRulesFlag) + } + setSpendingRulesFlag + }, { fn setAdminApiPayerAccount( data: &[u8], @@ -21018,6 +21762,17 @@ pub mod AccountConfig { } registerWalletDerivation }, + { + fn canExecuteActionWithSpendingRules( + data: &[u8], + ) -> alloy_sol_types::Result { + ::abi_decode_raw_validate( + data, + ) + .map(AccountConfigCalls::canExecuteActionWithSpendingRules) + } + canExecuteActionWithSpendingRules + }, { fn api_payers(data: &[u8]) -> alloy_sol_types::Result { ::abi_decode_raw_validate(data) @@ -21069,6 +21824,17 @@ pub mod AccountConfig { } updateActionMetadata }, + { + fn getSpendingRulesFlag( + data: &[u8], + ) -> alloy_sol_types::Result { + ::abi_decode_raw_validate( + data, + ) + .map(AccountConfigCalls::getSpendingRulesFlag) + } + getSpendingRulesFlag + }, { fn setApiPayers(data: &[u8]) -> alloy_sol_types::Result { ::abi_decode_raw_validate( @@ -21116,6 +21882,17 @@ pub mod AccountConfig { } apiPayerCount }, + { + fn setSpendingRulesFlag( + data: &[u8], + ) -> alloy_sol_types::Result { + ::abi_decode_raw_validate( + data, + ) + .map(AccountConfigCalls::setSpendingRulesFlag) + } + setSpendingRulesFlag + }, { fn setAdminApiPayerAccount( data: &[u8], @@ -21345,6 +22122,11 @@ pub mod AccountConfig { inner, ) } + Self::canExecuteActionWithSpendingRules(inner) => { + ::abi_encoded_size( + inner, + ) + } Self::canUseWalletInAction(inner) => { ::abi_encoded_size( inner, @@ -21393,6 +22175,11 @@ pub mod AccountConfig { Self::getPricing(inner) => { ::abi_encoded_size(inner) } + Self::getSpendingRulesFlag(inner) => { + ::abi_encoded_size( + inner, + ) + } Self::getWalletDerivation(inner) => { ::abi_encoded_size( inner, @@ -21566,6 +22353,11 @@ pub mod AccountConfig { inner, ) } + Self::setSpendingRulesFlag(inner) => { + ::abi_encoded_size( + inner, + ) + } Self::setUsageApiKey(inner) => { ::abi_encoded_size( inner, @@ -21691,6 +22483,12 @@ pub mod AccountConfig { out, ) } + Self::canExecuteActionWithSpendingRules(inner) => { + ::abi_encode_raw( + inner, + out, + ) + } Self::canUseWalletInAction(inner) => { ::abi_encode_raw( inner, @@ -21751,6 +22549,12 @@ pub mod AccountConfig { out, ) } + Self::getSpendingRulesFlag(inner) => { + ::abi_encode_raw( + inner, + out, + ) + } Self::getWalletDerivation(inner) => { ::abi_encode_raw( inner, @@ -21973,6 +22777,12 @@ pub mod AccountConfig { out, ) } + Self::setSpendingRulesFlag(inner) => { + ::abi_encode_raw( + inner, + out, + ) + } Self::setUsageApiKey(inner) => { ::abi_encode_raw( inner, @@ -22899,6 +23709,8 @@ pub mod AccountConfig { #[allow(missing_docs)] ServerTriggered(ServerTriggered), #[allow(missing_docs)] + SpendingRulesFlagSet(SpendingRulesFlagSet), + #[allow(missing_docs)] UsageApiKeyRemoved(UsageApiKeyRemoved), #[allow(missing_docs)] UsageApiKeySet(UsageApiKeySet), @@ -22915,6 +23727,11 @@ pub mod AccountConfig { // // Prefer using `SolInterface` methods instead. pub const SELECTORS: &'static [[u8; 32usize]] = &[ + [ + 1u8, 34u8, 133u8, 111u8, 58u8, 5u8, 88u8, 114u8, 27u8, 48u8, 72u8, 38u8, 227u8, + 141u8, 37u8, 118u8, 58u8, 21u8, 130u8, 84u8, 161u8, 205u8, 118u8, 198u8, 188u8, + 44u8, 62u8, 98u8, 153u8, 48u8, 153u8, 113u8, + ], [ 1u8, 244u8, 55u8, 145u8, 139u8, 103u8, 250u8, 246u8, 192u8, 35u8, 225u8, 74u8, 111u8, 114u8, 2u8, 164u8, 92u8, 140u8, 53u8, 46u8, 141u8, 219u8, 211u8, 229u8, @@ -23053,6 +23870,7 @@ pub mod AccountConfig { ]; // The names of the variants in the same order as `SELECTORS`. pub const VARIANT_NAMES: &'static [&'static str] = &[ + ::core::stringify!(SpendingRulesFlagSet), ::core::stringify!(PricingOperatorUpdated), ::core::stringify!(ApiPayersUpdated), ::core::stringify!(ActionAddedToGroup), @@ -23083,6 +23901,7 @@ pub mod AccountConfig { ]; // The signatures in the same order as `SELECTORS`. pub const SIGNATURES: &'static [&'static str] = &[ + ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, ::SIGNATURE, @@ -23133,7 +23952,7 @@ pub mod AccountConfig { #[automatically_derived] impl alloy_sol_types::SolEventInterface for AccountConfigEvents { const NAME: &'static str = "AccountConfigEvents"; - const COUNT: usize = 27usize; + const COUNT: usize = 28usize; fn decode_raw_log( topics: &[alloy_sol_types::Word], data: &[u8], @@ -23322,6 +24141,15 @@ pub mod AccountConfig { ) .map(Self::ServerTriggered) } + Some( + ::SIGNATURE_HASH, + ) => { + ::decode_raw_log( + topics, + data, + ) + .map(Self::SpendingRulesFlagSet) + } Some( ::SIGNATURE_HASH, ) => { @@ -23443,6 +24271,9 @@ pub mod AccountConfig { Self::ServerTriggered(inner) => { alloy_sol_types::private::IntoLogData::to_log_data(inner) } + Self::SpendingRulesFlagSet(inner) => { + alloy_sol_types::private::IntoLogData::to_log_data(inner) + } Self::UsageApiKeyRemoved(inner) => { alloy_sol_types::private::IntoLogData::to_log_data(inner) } @@ -23528,6 +24359,9 @@ pub mod AccountConfig { Self::ServerTriggered(inner) => { alloy_sol_types::private::IntoLogData::into_log_data(inner) } + Self::SpendingRulesFlagSet(inner) => { + alloy_sol_types::private::IntoLogData::into_log_data(inner) + } Self::UsageApiKeyRemoved(inner) => { alloy_sol_types::private::IntoLogData::into_log_data(inner) } @@ -23797,6 +24631,17 @@ pub mod AccountConfig { cidHash, }) } + //Creates a new call builder for the [`canExecuteActionWithSpendingRules`] function. + pub fn canExecuteActionWithSpendingRules( + &self, + apiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + cidHash: alloy::sol_types::private::primitives::aliases::U256, + ) -> alloy_contract::SolCallBuilder<&P, canExecuteActionWithSpendingRulesCall, N> { + self.call_builder(&canExecuteActionWithSpendingRulesCall { + apiKeyHash, + cidHash, + }) + } //Creates a new call builder for the [`canUseWalletInAction`] function. pub fn canUseWalletInAction( &self, @@ -23882,6 +24727,13 @@ pub mod AccountConfig { ) -> alloy_contract::SolCallBuilder<&P, getPricingCall, N> { self.call_builder(&getPricingCall { pricingItemId }) } + //Creates a new call builder for the [`getSpendingRulesFlag`] function. + pub fn getSpendingRulesFlag( + &self, + apiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + ) -> alloy_contract::SolCallBuilder<&P, getSpendingRulesFlagCall, N> { + self.call_builder(&getSpendingRulesFlagCall { apiKeyHash }) + } //Creates a new call builder for the [`getWalletDerivation`] function. pub fn getWalletDerivation( &self, @@ -24249,6 +25101,19 @@ pub mod AccountConfig { newRequestedApiPayerCount, }) } + //Creates a new call builder for the [`setSpendingRulesFlag`] function. + pub fn setSpendingRulesFlag( + &self, + accountApiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + usageApiKeyHash: alloy::sol_types::private::primitives::aliases::U256, + hasSpendingRules: bool, + ) -> alloy_contract::SolCallBuilder<&P, setSpendingRulesFlagCall, N> { + self.call_builder(&setSpendingRulesFlagCall { + accountApiKeyHash, + usageApiKeyHash, + hasSpendingRules, + }) + } //Creates a new call builder for the [`setUsageApiKey`] function. pub fn setUsageApiKey( &self, @@ -24498,6 +25363,12 @@ pub mod AccountConfig { pub fn ServerTriggered_filter(&self) -> alloy_contract::Event<&P, ServerTriggered, N> { self.event_filter::() } + //Creates a new event filter for the [`SpendingRulesFlagSet`] event. + pub fn SpendingRulesFlagSet_filter( + &self, + ) -> alloy_contract::Event<&P, SpendingRulesFlagSet, N> { + self.event_filter::() + } //Creates a new event filter for the [`UsageApiKeyRemoved`] event. pub fn UsageApiKeyRemoved_filter( &self, diff --git a/lit-api-server/src/accounts/mod.rs b/lit-api-server/src/accounts/mod.rs index 189596d5..156bbb73 100644 --- a/lit-api-server/src/accounts/mod.rs +++ b/lit-api-server/src/accounts/mod.rs @@ -911,32 +911,21 @@ pub async fn can_execute_action(api_key: &str, cid_hash: U256) -> Result { Ok(can_execute) } -/// Scoped binding for the spending-rules view added in lambda-parity PR 3. -/// Defined here (not via the giant generated binding) so it tracks the workspace -/// alloy version directly; fold into the generated binding once it is -/// regenerated on the canonical toolchain. See `plans/chipotle-lambda-parity.md`. -mod spending_view { - alloy::sol! { - #[sol(rpc)] - contract SpendingView { - function canExecuteActionWithSpendingRules( - uint256 apiKeyHash, - uint256 cidHash - ) external view returns (bool canExecute, bool hasSpendingRules); - function getSpendingRulesFlag(uint256 apiKeyHash) external view returns (bool); - function setSpendingRulesFlag( - uint256 accountApiKeyHash, - uint256 usageApiKeyHash, - bool hasSpendingRules - ) external; - } - } +async fn fetch_execute_and_spending( + account_api_key_hash: U256, + cid_hash_eth: U256, +) -> Result<(bool, bool)> { + let contract = get_read_only_account_config_contract().await?; + let result = contract + .canExecuteActionWithSpendingRules(account_api_key_hash, cid_hash_eth) + .call() + .await?; + Ok((result.canExecute, result.hasSpendingRules)) } /// Read a usage key's on-chain `hasSpendingRules` flag (uncached; owner path). pub async fn get_spending_rules_flag(usage_api_key_or_hash: &str) -> Result { - let (client, address) = crate::accounts::signable_contract::read_only_client_and_address()?; - let contract = spending_view::SpendingView::new(address, client); + let contract = get_read_only_account_config_contract().await?; let usage_hash = usage_api_key_to_hash(usage_api_key_or_hash); Ok(contract.getSpendingRulesFlag(usage_hash).call().await?) } @@ -952,14 +941,8 @@ pub async fn set_spending_rules_flag( usage_api_key_or_hash: &str, has_spending_rules: bool, ) -> Result { - let signer_handle = signer_pool.request().await?; - let client = signer_handle - .client - .ok_or(anyhow::anyhow!("No signer available"))?; - let lease = signer_handle.lease; - let address = crate::accounts::signable_contract::account_config_address()?; - let contract = spending_view::SpendingView::new(address, client.clone()); - + let (contract, signer_lease, client) = + get_signable_account_config_contract(signer_pool.clone()).await?; let account_api_key_hash = api_key_hash(api_key); let usage_api_key_hash = usage_api_key_to_hash(usage_api_key_or_hash); tracing::info!( @@ -971,24 +954,11 @@ pub async fn set_spending_rules_flag( let function_call = contract.setSpendingRulesFlag(account_api_key_hash, usage_api_key_hash, has_spending_rules); - let result = send_transaction(function_call, signer_pool, lease, client).await?; + let result = send_transaction(function_call, signer_pool, signer_lease, client).await?; blockchain_cache::invalidate_for_keys(api_key, usage_api_key_or_hash); Ok(result) } -async fn fetch_execute_and_spending( - account_api_key_hash: U256, - cid_hash_eth: U256, -) -> Result<(bool, bool)> { - let (client, address) = crate::accounts::signable_contract::read_only_client_and_address()?; - let contract = spending_view::SpendingView::new(address, client); - let result = contract - .canExecuteActionWithSpendingRules(account_api_key_hash, cid_hash_eth) - .call() - .await?; - Ok((result.canExecute, result.hasSpendingRules)) -} - /// Combined hot-path check: `(can_execute, has_spending_rules)` in a single RPC. /// /// `has_spending_rules` is the zero-latency gate for per-key Lambda-parity diff --git a/lit-api-server/src/accounts/signable_contract.rs b/lit-api-server/src/accounts/signable_contract.rs index 165e2399..ef94cc56 100644 --- a/lit-api-server/src/accounts/signable_contract.rs +++ b/lit-api-server/src/accounts/signable_contract.rs @@ -146,25 +146,6 @@ pub(crate) fn get_read_only_client() -> Result { }) } -/// Read-only provider + the AccountConfig address, for ad-hoc scoped `sol!` -/// interfaces that target functions not yet present in the regenerated giant -/// binding (e.g. the spending-rules view from lambda-parity PR 3). Tracks the -/// workspace alloy version directly; fold callers into the generated binding -/// once it is regenerated on the canonical toolchain. -pub(crate) fn read_only_client_and_address() -> Result<(SigningClient, Address)> { - Ok((get_read_only_client()?, account_config_address()?)) -} - -/// The configured AccountConfig diamond address. -pub(crate) fn account_config_address() -> Result
{ - let node_config = GLOBAL_NODE_CONFIG - .get() - .ok_or_else(|| anyhow::anyhow!("Node configuration not found"))?; - Ok(Address::from_slice(&hex_to_bytes( - &node_config.contract_address, - )?)) -} - pub(crate) async fn get_read_only_account_config_contract() -> Result { let client = GLOBAL_READ_ONLY_CLIENT .get() From df24b1d995c86894b96eebfb6d1d7f19526d05bd Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 15 Sep 2026 12:26:55 -0700 Subject: [PATCH 09/10] chore(k6): regenerate litApiServer.ts client for the spending-rules endpoints Co-Authored-By: Claude Fable 5.1 --- k6/litApiServer.ts | 288 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) diff --git a/k6/litApiServer.ts b/k6/litApiServer.ts index 7682d4fd..19278dd8 100644 --- a/k6/litApiServer.ts +++ b/k6/litApiServer.ts @@ -362,6 +362,113 @@ export interface UpdateUsageApiKeyMetadataRequest { description: string; } +/** + * Stored rules, or null when the key has none. + * @nullable + */ +export type SpendingRulesResponseRules = SpendingRulesItem | null; + +/** + * Response for set_spending_rules / get_spending_rules. + */ +export interface SpendingRulesResponse { + /** The usage key's 0x-prefixed 32-byte hash the rules are stored under. */ + usage_api_key_hash: string; + /** + * Stored rules, or null when the key has none. + * @nullable + */ + rules?: SpendingRulesResponseRules; + /** + * Cents spent in the current rolling window (null when no cap / no usage yet). + * @nullable + */ + spent_cents_in_window?: number | null; + /** Whether the on-chain `hasSpendingRules` gate is set for this key. Should equal `rules.is_some()`; a mismatch means a previous write half-failed — re-run set_spending_rules or remove_spending_rules to reconcile. */ + on_chain_flag: boolean; +} + +/** + * The stored spending rules for a usage key (see set_spending_rules). + */ +export interface SpendingRulesItem { + /** @nullable */ + spend_cap_cents?: number | null; + /** @nullable */ + spend_window_seconds?: number | null; + /** @nullable */ + rate_limit_rps?: number | null; + /** @nullable */ + rate_limit_burst?: number | null; + /** @nullable */ + max_concurrency?: number | null; + /** @nullable */ + ip_rate_limit_rps?: number | null; + /** @nullable */ + ip_rate_limit_burst?: number | null; + /** @nullable */ + allowed_origins?: string[] | null; + enabled: boolean; +} + +/** + * Request for set_spending_rules: make a usage key safe to embed in a frontend by bounding its blast radius. Omitted limits mean "no limit"; paired fields must be supplied together. Master API key via header. + */ +export interface SetSpendingRulesRequest { + /** The usage API key (raw key or its 0x-prefixed 32-byte hash as returned by list_api_keys) the rules apply to. Must belong to the calling account. */ + usage_api_key: string; + /** + * Rolling spend cap in cents; pair with `spend_window_seconds`. 402 once reached. + * @nullable + */ + spend_cap_cents?: number | null; + /** + * Length of the rolling spend window in seconds; pair with `spend_cap_cents`. + * @nullable + */ + spend_window_seconds?: number | null; + /** + * Sustained requests/second for the key across all callers; pair with `rate_limit_burst`. + * @nullable + */ + rate_limit_rps?: number | null; + /** + * Burst allowance for the per-key limit; pair with `rate_limit_rps`. + * @nullable + */ + rate_limit_burst?: number | null; + /** + * Maximum simultaneously-executing actions for the key (429 above it). + * @nullable + */ + max_concurrency?: number | null; + /** + * Sustained requests/second per client IP; pair with `ip_rate_limit_burst`. + * @nullable + */ + ip_rate_limit_rps?: number | null; + /** + * Burst allowance for the per-IP limit; pair with `ip_rate_limit_rps`. + * @nullable + */ + ip_rate_limit_burst?: number | null; + /** + * Browser origins (`scheme://host[:port]`, `*.` host prefix allowed) permitted to use the key. When set, requests without a matching `Origin` header get 403. + * @nullable + */ + allowed_origins?: string[] | null; + /** Set false to keep the rules stored but not enforced. Defaults to true. */ + enabled?: boolean; +} + +/** + * Request for remove_spending_rules / get_spending_rules. Master API key via header. + */ +export interface UsageKeySpendingRulesRequest { + /** The usage API key (raw key or its 0x-prefixed 32-byte hash). */ + usage_api_key: string; +} + /** * One item from list_groups, list_wallets, list_wallets_in_group, or list_actions (AccountConfig.sol Metadata). */ @@ -861,6 +968,37 @@ export type UpdateUsageApiKeyMetadataHeaders = { export type UpdateUsageApiKeyMetadataDefault = AccountOpResponse | ErrMessage; +export type SetSpendingRulesHeaders = { + /** + * Account or usage API key. Alternatively use Authorization: Bearer . + */ + "X-Api-Key": string; +}; + +export type SetSpendingRulesDefault = SpendingRulesResponse | ErrMessage; + +export type RemoveSpendingRulesHeaders = { + /** + * Account or usage API key. Alternatively use Authorization: Bearer . + */ + "X-Api-Key": string; +}; + +export type RemoveSpendingRulesDefault = SpendingRulesResponse | ErrMessage; + +export type GetSpendingRulesParams = { + usage_api_key: string; +}; + +export type GetSpendingRulesHeaders = { + /** + * Account or usage API key. Alternatively use Authorization: Bearer . + */ + "X-Api-Key": string; +}; + +export type GetSpendingRulesDefault = SpendingRulesResponse | ErrMessage; + export type ListGroupsParams = { /** * @minimum 0 @@ -2252,6 +2390,156 @@ NOT IDEMPOTENT: every call returns a brand-new wallet (a fresh random derivation }; } + /** + * Set (create or replace) the spending rules for one of your usage API keys and turn on its on-chain `hasSpendingRules` gate. Use this to make a usage key safe to embed in a frontend: a rolling spend cap (402 when reached), per-key and per-client-IP rate limits and a concurrency cap (429), and a browser origin allowlist (403). Keys without rules pay no extra latency. + +Requires the account's master API key. Returns 503 if this node is not connected to the spending-rules store, 400 if the rules are invalid, and 403 if the usage key does not belong to your account. + */ + setSpendingRules( + setSpendingRulesRequest: SetSpendingRulesRequest, + headers: SetSpendingRulesHeaders, + requestParameters?: Params, + ): { + response: Response; + data: SetSpendingRulesDefault; + operationId: string; + } { + const k6url = new URL(this.cleanBaseUrl + `/set_spending_rules`); + const mergedRequestParameters = this._mergeRequestParameters( + requestParameters || {}, + this.commonRequestParameters, + ); + const response = http.request( + "POST", + k6url.toString(), + JSON.stringify(setSpendingRulesRequest), + { + ...mergedRequestParameters, + headers: { + ...mergedRequestParameters?.headers, + "Content-Type": "application/json", + // In the schema, headers can be of any type like number but k6 accepts only strings as headers, hence converting all headers to string + ...Object.fromEntries( + Object.entries(headers || {}).map(([key, value]) => [ + key, + String(value), + ]), + ), + }, + }, + ); + let data; + + try { + data = response.json(); + } catch { + data = response.body; + } + return { + response, + data, + operationId: "set_spending_rules", + }; + } + + /** + * Remove the spending rules from one of your usage API keys and clear its on-chain gate, returning it to unrestricted (account-level) limits. + */ + removeSpendingRules( + usageKeySpendingRulesRequest: UsageKeySpendingRulesRequest, + headers: RemoveSpendingRulesHeaders, + requestParameters?: Params, + ): { + response: Response; + data: RemoveSpendingRulesDefault; + operationId: string; + } { + const k6url = new URL(this.cleanBaseUrl + `/remove_spending_rules`); + const mergedRequestParameters = this._mergeRequestParameters( + requestParameters || {}, + this.commonRequestParameters, + ); + const response = http.request( + "POST", + k6url.toString(), + JSON.stringify(usageKeySpendingRulesRequest), + { + ...mergedRequestParameters, + headers: { + ...mergedRequestParameters?.headers, + "Content-Type": "application/json", + // In the schema, headers can be of any type like number but k6 accepts only strings as headers, hence converting all headers to string + ...Object.fromEntries( + Object.entries(headers || {}).map(([key, value]) => [ + key, + String(value), + ]), + ), + }, + }, + ); + let data; + + try { + data = response.json(); + } catch { + data = response.body; + } + return { + response, + data, + operationId: "remove_spending_rules", + }; + } + + /** + * Read the spending rules, current-window spend and on-chain gate state for one of your usage API keys. `usage_api_key` may be the raw key or its hash. + */ + getSpendingRules( + params: GetSpendingRulesParams, + headers: GetSpendingRulesHeaders, + requestParameters?: Params, + ): { + response: Response; + data: GetSpendingRulesDefault; + operationId: string; + } { + const k6url = new URL( + this.cleanBaseUrl + + `/get_spending_rules` + + `?${new URLSearchParams(params).toString()}`, + ); + const mergedRequestParameters = this._mergeRequestParameters( + requestParameters || {}, + this.commonRequestParameters, + ); + const response = http.request("GET", k6url.toString(), undefined, { + ...mergedRequestParameters, + headers: { + ...mergedRequestParameters?.headers, + // In the schema, headers can be of any type like number but k6 accepts only strings as headers, hence converting all headers to string + ...Object.fromEntries( + Object.entries(headers || {}).map(([key, value]) => [ + key, + String(value), + ]), + ), + }, + }); + let data; + + try { + data = response.json(); + } catch { + data = response.body; + } + return { + response, + data, + operationId: "get_spending_rules", + }; + } + listGroups( params: ListGroupsParams, headers: ListGroupsHeaders, From 2c5428d1c45341744338cf82afcca1d00711a690 Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Tue, 15 Sep 2026 12:54:03 -0700 Subject: [PATCH 10/10] deploy: plumb LIT_PAYMENTS_INTERNAL_URL to the CVM; renumber spending_rules migration - docker-compose.phala.yml reads LIT_PAYMENTS_INTERNAL_URL; deploy-staging and manual_phala-envs-update pass vars.LIT_PAYMENTS_STAGING_URL (the same lit-payments the next/dev dashboards already talk to). Prod workflows are untouched: unset -> the feature stays disabled there. - Migration renumbered 20260604 -> 20260915 so it sorts after everything already applied on the staging database (it has never been applied anywhere). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/deploy-staging.yml | 3 ++- .github/workflows/manual_phala-envs-update.yml | 3 ++- docker-compose.phala.yml | 5 +++++ ..._spending_rules.sql => 20260915000001_spending_rules.sql} | 0 4 files changed, 9 insertions(+), 2 deletions(-) rename lit-payments/migrations/{20260604000001_spending_rules.sql => 20260915000001_spending_rules.sql} (100%) diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 395ceb8b..03a85f3b 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -430,7 +430,8 @@ jobs: -e "CERTBOT_AWS_SECRET_ACCESS_KEY=$CERTBOT_AWS_SECRET_ACCESS_KEY" \ -e "CERTBOT_AWS_ROLE_ARN=$CERTBOT_AWS_ROLE_ARN" \ -e "CERTBOT_AWS_REGION=${{ vars.CERTBOT_AWS_REGION }}" \ - -e "LIT_INTERNAL_SHARED_SECRET=$LIT_INTERNAL_SHARED_SECRET" + -e "LIT_INTERNAL_SHARED_SECRET=$LIT_INTERNAL_SHARED_SECRET" \ + -e "LIT_PAYMENTS_INTERNAL_URL=${{ vars.LIT_PAYMENTS_STAGING_URL }}" wait-for-api-available: needs: [deploy, determine-target, detect-changes] diff --git a/.github/workflows/manual_phala-envs-update.yml b/.github/workflows/manual_phala-envs-update.yml index 5a40b895..b500d94b 100644 --- a/.github/workflows/manual_phala-envs-update.yml +++ b/.github/workflows/manual_phala-envs-update.yml @@ -87,7 +87,8 @@ jobs: -e "CERTBOT_AWS_SECRET_ACCESS_KEY=$CERTBOT_AWS_SECRET_ACCESS_KEY" \ -e "CERTBOT_AWS_ROLE_ARN=$CERTBOT_AWS_ROLE_ARN" \ -e "CERTBOT_AWS_REGION=${{ vars.CERTBOT_AWS_REGION }}" \ - -e "LIT_INTERNAL_SHARED_SECRET=$LIT_INTERNAL_SHARED_SECRET" + -e "LIT_INTERNAL_SHARED_SECRET=$LIT_INTERNAL_SHARED_SECRET" \ + -e "LIT_PAYMENTS_INTERNAL_URL=${{ vars.LIT_PAYMENTS_STAGING_URL }}" - name: Start CVM if: inputs.start diff --git a/docker-compose.phala.yml b/docker-compose.phala.yml index 4af47c99..8a86a65b 100644 --- a/docker-compose.phala.yml +++ b/docker-compose.phala.yml @@ -129,6 +129,11 @@ services: # internal cache-invalidation endpoint (/internal/invalidate_balance_cache). # Must match the value set on lit-payments (Railway env var of the same name). LIT_INTERNAL_SHARED_SECRET: ${LIT_INTERNAL_SHARED_SECRET} + # Base URL of lit-payments, for the per-key spending-rules store + # (/internal/spending-*; authed with LIT_INTERNAL_SHARED_SECRET above). + # Unset/empty = spending-rules enforcement and the set_spending_rules + # endpoints are disabled on this node. See lit-api-server/src/core/spending_rules.rs. + LIT_PAYMENTS_INTERNAL_URL: ${LIT_PAYMENTS_INTERNAL_URL} # gVisor any-language runner gate (CPL-359, CPL-361). Rendered per-deploy: # testing/manual deploys substitute "true"; the production workflow # substitutes "false" (see deploy-staging.yml / deploy-prod-1-propose.yml / diff --git a/lit-payments/migrations/20260604000001_spending_rules.sql b/lit-payments/migrations/20260915000001_spending_rules.sql similarity index 100% rename from lit-payments/migrations/20260604000001_spending_rules.sql rename to lit-payments/migrations/20260915000001_spending_rules.sql