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/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, 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 4825b706..c6251597 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 @@ -125,6 +125,13 @@ library AppStorage { // function of the public path). Deliberately NOT cleared by // removeWalletDerivation — only the first owner may ever (re-)register. mapping(address => uint256) pkpIdToOwnerMaster; + // 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 0bc00815..a13b44f5 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 @@ -434,6 +434,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 c4fb8391..c2627889 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, @@ -361,6 +366,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, diff --git a/lit-api-server/src/accounts/blockchain_cache.rs b/lit-api-server/src/accounts/blockchain_cache.rs index b58b6bf6..ada500cb 100644 --- a/lit-api-server/src/accounts/blockchain_cache.rs +++ b/lit-api-server/src/accounts/blockchain_cache.rs @@ -120,6 +120,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 @@ -147,6 +149,11 @@ impl BlockchainCache { .max_capacity(MAX_CAPACITY) .expire_after(PairPermissionExpiry) .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) @@ -156,6 +163,7 @@ impl BlockchainCache { execute_action, use_wallet, execute_and_wallet, + execute_and_spending, wallet_derivation, generations: RwLock::new(HashMap::new()), } @@ -197,6 +205,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(); @@ -219,6 +234,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/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 9a50e0b1..156bbb73 100644 --- a/lit-api-server/src/accounts/mod.rs +++ b/lit-api-server/src/accounts/mod.rs @@ -911,6 +911,86 @@ pub async fn can_execute_action(api_key: &str, cid_hash: U256) -> Result { Ok(can_execute) } +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 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?) +} + +/// 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 (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!( + "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, signer_lease, client).await?; + blockchain_cache::invalidate_for_keys(api_key, usage_api_key_or_hash); + Ok(result) +} + +/// 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/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/core_features.rs b/lit-api-server/src/core/core_features.rs index b37423ff..44a8b789 100644 --- a/lit-api-server/src/core/core_features.rs +++ b/lit-api-server/src/core/core_features.rs @@ -1,4 +1,4 @@ -use crate::accounts::can_execute_action; +use crate::accounts::can_execute_action_with_spending_rules; use crate::accounts::chain_config::{ChainConfig, ConfigKeys}; use crate::actions::client::ClientBuilder; use crate::actions::client::models::DenoExecutionEnv; @@ -9,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::{ @@ -39,6 +41,8 @@ pub async fn lit_action( http_client: &reqwest::Client, chain_config: Arc, stripe_state: Option>, + spending: &SpendingRulesState, + spending_ctx: &SpendingContext, lit_action_request: Json, ) -> Result { let request_id = request_span.request_id.clone(); @@ -56,9 +60,13 @@ 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) - .instrument(tracing::debug_span!("lit_action::can_execute_action")) - .await?; + // 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 { let msg = format!( "The provided API key is not authorized to execute the specified action ({derived_ipfs_id}/{cid_hash})." @@ -66,6 +74,14 @@ pub async fn lit_action( return Err(ApiStatus::forbidden(msg)); } + // 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 .insert(derived_ipfs_id.clone(), Arc::new(code_to_run.clone())) @@ -131,6 +147,7 @@ pub async fn lit_action( startup_script: None, }; + let exec_start = std::time::Instant::now(); let result = match client .execute_js(execution_options) .instrument(tracing::debug_span!("lit_action::execute_js")) @@ -140,6 +157,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) => { @@ -243,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 { @@ -260,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 @@ -318,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")) @@ -326,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/mod.rs b/lit-api-server/src/core/mod.rs index 6e50f560..5bf495be 100644 --- a/lit-api-server/src/core/mod.rs +++ b/lit-api-server/src/core/mod.rs @@ -4,6 +4,7 @@ pub mod account_management; pub mod cache_metadata; 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..fec42269 --- /dev/null +++ b/lit-api-server/src/core/spending_rules.rs @@ -0,0 +1,934 @@ +//! 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: +//! +//! - **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), +//! - **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`. +//! +//! 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, 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). +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; +/// 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). +/// `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, Clone, Serialize, Deserialize)] +pub struct RulesWithUsage { + pub rules: RuleSet, + pub usage: Option, +} + +/// 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 +/// 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>, + /// Keyed by `"|"`; swept when large (see + /// [`IP_BUCKET_SWEEP_THRESHOLD`]). + ip_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 + /// `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 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 \ + or LIT_INTERNAL_SHARED_SECRET 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()), + 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. `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)); + } + 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(); + + // 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 { + 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)" + ))); + } + } + + // 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(); + 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 + }) + } + + /// 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(); + 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}"); + } + }); + } +} + +/// 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`). +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}")) +} + +/// 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::*; + + #[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 + } + + 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"); + 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/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/actions.rs b/lit-api-server/src/core/v1/endpoints/actions.rs index a4996358..cf8285ce 100644 --- a/lit-api-server/src/core/v1/endpoints/actions.rs +++ b/lit-api-server/src/core/v1/endpoints/actions.rs @@ -5,8 +5,10 @@ use crate::actions::grpc::GrpcClientPool; use crate::actions::gvisor::GvisorEnabled; use crate::core::cache_metadata::CacheMetadataIndex; 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; @@ -34,6 +36,8 @@ pub(super) async fn lit_action( http_client: &State, chain_config: &State>, stripe_state: &State>>, + spending: &State, + spending_ctx: SpendingContext, lit_action_request: Json, ) -> OpenApiResponse { OpenApiResponse { @@ -47,6 +51,8 @@ pub(super) async fn lit_action( http_client.inner(), chain_config.inner().clone(), stripe_state.inner().clone(), + spending.inner(), + &spending_ctx, lit_action_request, ) .await, @@ -78,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 { @@ -91,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/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/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-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/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-api-server/src/main.rs b/lit-api-server/src/main.rs index 04cfd776..f64bdec4 100644 --- a/lit-api-server/src/main.rs +++ b/lit-api-server/src/main.rs @@ -540,6 +540,7 @@ fn build_rocket( .manage(cpu_monitor) .manage(rate_limiter) .manage(stripe_state) + .manage(core::spending_rules::SpendingRulesState::from_env()) .manage(internal_config) .manage(auth_resolver) .manage(supported_languages) 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/migrations/20260915000001_spending_rules.sql b/lit-payments/migrations/20260915000001_spending_rules.sql new file mode 100644 index 00000000..4e67c44e --- /dev/null +++ b/lit-payments/migrations/20260915000001_spending_rules.sql @@ -0,0 +1,65 @@ +-- 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), + -- 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[], + + -- 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) + ), + CONSTRAINT ip_rate_limit_complete CHECK ( + (ip_rate_limit_rps IS NULL) = (ip_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/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 80bd611d..5764249e 100644 --- a/lit-payments/src/config.rs +++ b/lit-payments/src/config.rs @@ -47,6 +47,12 @@ 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. 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., /// `https://api.litprotocol.com`. This is the ONLY remaining @@ -206,6 +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") + .or_else(|| optional_trimmed("LIT_INTERNAL_SHARED_SECRET")), lit_api_server_base_url: required("LIT_API_SERVER_BASE_URL")? .trim_end_matches('/') .to_string(), diff --git a/lit-payments/src/lib.rs b/lit-payments/src/lib.rs index 7fc9c510..c0a76875 100644 --- a/lit-payments/src/lib.rs +++ b/lit-payments/src/lib.rs @@ -18,3 +18,4 @@ mod litkey_reconciler_tests; 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 aeb3ad35..855739c5 100644 --- a/lit-payments/src/main.rs +++ b/lit-payments/src/main.rs @@ -11,6 +11,7 @@ use lit_payments::auto_topup::webhook::mutex::PerCustomerMutex; use lit_payments::billing as billing_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; @@ -121,6 +122,14 @@ 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_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, billing_routes::auto_topup_config::put_auto_topup_config, diff --git a/lit-payments/src/spending/db.rs b/lit-payments/src/spending/db.rs new file mode 100644 index 00000000..37924393 --- /dev/null +++ b/lit-payments/src/spending/db.rs @@ -0,0 +1,123 @@ +//! 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, + 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, + 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, + 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() + 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.ip_rate_limit_rps) + .bind(req.ip_rate_limit_burst) + .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..4d7c2cca --- /dev/null +++ b/lit-payments/src/spending/routes.rs @@ -0,0 +1,188 @@ +//! Spending-rules HTTP routes. +//! +//! Operator-authed CRUD under `/api/spending-rules` (browser admin UI) and +//! `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; +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 })) +} + +/// `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. +#[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..29cfce6a --- /dev/null +++ b/lit-payments/src/spending/types.rs @@ -0,0 +1,268 @@ +//! 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 ip_rate_limit_rps: Option, + pub ip_rate_limit_burst: 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, + /// 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. + #[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()); + } + 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), + ] { + 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), + ("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 + { + return Err(format!("{name} must be positive")); + } + } + 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::*; + + 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()); + } + + #[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}"); + } + } +} 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.