Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ library AppStorage {
EnumerableSet.StringSet nodeConfigurationKeys;
mapping(string => string) nodeConfigurationValues;
uint256 serverTriggerValue;
// Lambda-parity: per-usage-key flag gating off-hot-path spending-rule
// enforcement (rolling spend cap, rate/concurrency limits, origin
// allowlist) stored off-chain. False for every existing key, so the
// gateway does zero extra work unless it is explicitly set. Appended at
// the end of root storage so the existing layout is untouched.
// See plans/chipotle-lambda-parity.md.
mapping(uint256 => bool) usageKeyHasSpendingRules;
}

function getStorage()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,29 @@ contract ViewsFacet {
return apiKeyCanExecuteForAnyGroup(apiKeyHash, groupIds);
}

/// @notice Whether a usage API key has off-chain spending rules the gateway
/// must enforce. False for every key that never set it, so the
/// gateway's hot path stays free for keys without rules.
function getSpendingRulesFlag(
uint256 apiKeyHash
) public view returns (bool) {
return AppStorage.getStorage().usageKeyHasSpendingRules[apiKeyHash];
}

/// @notice Combined hot-path check: returns (canExecute, hasSpendingRules)
/// in a single call so the gateway reads both in one RPC and pays no
/// extra round trip for the spending-rules gate.
/// See plans/chipotle-lambda-parity.md.
function canExecuteActionWithSpendingRules(
uint256 apiKeyHash,
uint256 cidHash
) public view returns (bool canExecute, bool hasSpendingRules) {
canExecute = canExecuteAction(apiKeyHash, cidHash);
hasSpendingRules = AppStorage.getStorage().usageKeyHasSpendingRules[
apiKeyHash
];
}

function canUseWalletInAction(
uint256 apiKeyHash,
uint256 cidHash,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -321,6 +326,28 @@ contract WritesFacet {
emit UsageApiKeySet(masterAccountApiKeyHash, usageApiKeyHash);
}

/// @notice Set the off-chain spending-rules flag for a usage API key.
/// @dev When true, the gateway enforces this key's off-chain spending rules
/// (rolling spend cap, rate/concurrency limits, origin allowlist); when
/// false it skips all of that on the hot path. Same account-access
/// control as setUsageApiKey. The detailed rules live off-chain (see
/// lit-payments); this only flips the gate. See
/// plans/chipotle-lambda-parity.md.
function setSpendingRulesFlag(
uint256 accountApiKeyHash,
uint256 usageApiKeyHash,
bool hasSpendingRules
) public {
SecurityLib.revertIfNoAccountAccess(accountApiKeyHash, msg.sender);
AppStorage.AccountConfigStorage storage s = AppStorage.getStorage();
s.usageKeyHasSpendingRules[usageApiKeyHash] = hasSpendingRules;
emit SpendingRulesFlagSet(
accountApiKeyHash,
usageApiKeyHash,
hasSpendingRules
);
}

function addGroup(
uint256 apiKeyHash,
string memory name,
Expand Down
20 changes: 20 additions & 0 deletions lit-api-server/src/accounts/blockchain_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct BlockchainCache {
use_wallet: Cache<String, bool>,
/// `can_execute_action_and_use_wallet` results.
execute_and_wallet: Cache<String, (bool, bool)>,
/// `can_execute_action_with_spending_rules` results: (canExecute, hasSpendingRules).
execute_and_spending: Cache<String, (bool, bool)>,
/// `get_wallet_derivation` results.
wallet_derivation: Cache<String, U256>,
/// Per-account generation counter keyed by the string representation of
Expand Down Expand Up @@ -62,6 +64,11 @@ impl BlockchainCache {
.time_to_idle(ttl)
.time_to_live(ttl)
.build();
let execute_and_spending = Cache::builder()
.max_capacity(MAX_CAPACITY)
.time_to_idle(ttl)
.time_to_live(ttl)
.build();
let wallet_derivation = Cache::builder()
.max_capacity(MAX_CAPACITY)
.time_to_idle(ttl)
Expand All @@ -71,6 +78,7 @@ impl BlockchainCache {
execute_action,
use_wallet,
execute_and_wallet,
execute_and_spending,
wallet_derivation,
generations: RwLock::new(HashMap::new()),
}
Expand Down Expand Up @@ -112,6 +120,13 @@ impl BlockchainCache {
format!("{h}:g{g}:ew:{cid_hash}:{wallet:#x}")
}

/// Build a cache key for `can_execute_action_with_spending_rules`.
pub fn execute_and_spending_key(&self, api_key_hash: U256, cid_hash: U256) -> String {
let h = api_key_hash.to_string();
let g = self.generation(&h);
format!("{h}:g{g}:es:{cid_hash}")
}

/// Build a cache key for `get_wallet_derivation`.
pub fn wallet_derivation_key(&self, api_key_hash: U256, wallet: Address) -> String {
let h = api_key_hash.to_string();
Expand All @@ -134,6 +149,11 @@ impl BlockchainCache {
&self.execute_and_wallet
}

/// Reference to the `can_execute_action_with_spending_rules` cache.
pub fn execute_and_spending_cache(&self) -> &Cache<String, (bool, bool)> {
&self.execute_and_spending
}

/// Reference to the `get_wallet_derivation` cache.
pub fn wallet_derivation_cache(&self) -> &Cache<String, U256> {
&self.wallet_derivation
Expand Down
61 changes: 61 additions & 0 deletions lit-api-server/src/accounts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,67 @@ pub async fn can_execute_action(api_key: &str, cid_hash: U256) -> Result<bool> {
Ok(can_execute)
}

/// Scoped binding for the spending-rules view added in lambda-parity PR 3.
/// Defined here (not via the giant generated binding) so it tracks the workspace
/// alloy version directly; fold into the generated binding once it is
/// regenerated on the canonical toolchain. See `plans/chipotle-lambda-parity.md`.
mod spending_view {
alloy::sol! {
#[sol(rpc)]
contract SpendingView {
function canExecuteActionWithSpendingRules(
uint256 apiKeyHash,
uint256 cidHash
) external view returns (bool canExecute, bool hasSpendingRules);
}
}
}

async fn fetch_execute_and_spending(
account_api_key_hash: U256,
cid_hash_eth: U256,
) -> Result<(bool, bool)> {
let (client, address) = crate::accounts::signable_contract::read_only_client_and_address()?;
let contract = spending_view::SpendingView::new(address, client);
let result = contract
.canExecuteActionWithSpendingRules(account_api_key_hash, cid_hash_eth)
.call()
.await?;
Ok((result.canExecute, result.hasSpendingRules))
}

/// Combined hot-path check: `(can_execute, has_spending_rules)` in a single RPC.
///
/// `has_spending_rules` is the zero-latency gate for per-key Lambda-parity
/// controls — false for every key that never set it, so the common path does no
/// extra work. See `plans/chipotle-lambda-parity.md`.
#[instrument(
name = "accounts::can_execute_action_with_spending_rules",
level = "debug",
skip_all,
err
)]
pub async fn can_execute_action_with_spending_rules(
api_key: &str,
cid_hash: U256,
) -> Result<(bool, bool)> {
let account_api_key_hash = api_key_hash(api_key);
let cid_hash_eth = cid_hash;

if let Some(cache) = blockchain_cache::get() {
let key = cache.execute_and_spending_key(account_api_key_hash, cid_hash);
return cache
.execute_and_spending_cache()
.try_get_with(key, async move {
fetch_execute_and_spending(account_api_key_hash, cid_hash_eth).await
})
.await
.map_err(|e: Arc<anyhow::Error>| 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",
Expand Down
15 changes: 15 additions & 0 deletions lit-api-server/src/accounts/signable_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ pub(crate) fn get_read_only_client() -> Result<SigningClient> {
})
}

/// Read-only provider + the AccountConfig address, for ad-hoc scoped `sol!`
/// interfaces that target functions not yet present in the regenerated giant
/// binding (e.g. the spending-rules view from lambda-parity PR 3). Tracks the
/// workspace alloy version directly; fold callers into the generated binding
/// once it is regenerated on the canonical toolchain.
pub(crate) fn read_only_client_and_address() -> Result<(SigningClient, Address)> {
let client = get_read_only_client()?;
let node_config = GLOBAL_NODE_CONFIG
.get()
.ok_or_else(|| anyhow::anyhow!("Node configuration not found"))?;
let account_config_address =
Address::from_slice(&hex_to_bytes(&node_config.contract_address)?);
Ok((client, account_config_address))
}

pub(crate) async fn get_read_only_account_config_contract() -> Result<AccountConfigInstance> {
let client = GLOBAL_READ_ONLY_CLIENT
.get()
Expand Down
20 changes: 18 additions & 2 deletions lit-api-server/src/core/core_features.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::accounts::can_execute_action;
use crate::accounts::can_execute_action_with_spending_rules;
use crate::accounts::chain_config::{ChainConfig, ConfigKeys};
use crate::core::spending_rules::SpendingRulesState;
use crate::actions::client::ClientBuilder;
use crate::actions::client::models::DenoExecutionEnv;
use crate::actions::client::{
Expand Down Expand Up @@ -32,6 +33,7 @@ pub async fn lit_action(
http_client: &reqwest::Client,
chain_config: Arc<ChainConfig>,
stripe_state: Option<Arc<StripeState>>,
spending: &SpendingRulesState,
lit_action_request: Json<LitActionRequest>,
) -> Result<LitActionResponse, ApiStatus> {
let request_id = request_span.request_id.clone();
Expand All @@ -49,7 +51,10 @@ pub async fn lit_action(
)
.await?;
let cid_hash = ipfs_cid_to_u256(&derived_ipfs_id)?;
let can_execute = can_execute_action(api_key, cid_hash)
// Single multicall returns the execute permission AND the zero-latency
// spending-rules gate. has_spending_rules is false for almost every key, so
// the spending-rules path below is skipped entirely for them.
let (can_execute, has_spending_rules) = can_execute_action_with_spending_rules(api_key, cid_hash)
.instrument(tracing::debug_span!("lit_action::can_execute_action"))
.await?;
if !can_execute {
Expand All @@ -59,6 +64,11 @@ pub async fn lit_action(
return Err(ApiStatus::forbidden(msg));
}

// Enforce per-key spending rules (rolling cap / rate / concurrency) before
// execution. Inert unless the key is flagged AND enforcement is configured.
// The returned admission holds any concurrency permit until end of scope.
let admission = spending.admit(api_key, has_spending_rules).await?;

// Cache after authorization so unauthorized requests cannot pollute the cache.
ipfs_cache
.insert(derived_ipfs_id.clone(), Arc::new(code_to_run.clone()))
Expand Down Expand Up @@ -96,6 +106,7 @@ pub async fn lit_action(
action_ipfs_id: Some(derived_ipfs_id),
};

let exec_start = std::time::Instant::now();
let result = match client
.execute_js(execution_options)
.instrument(tracing::debug_span!("lit_action::execute_js"))
Expand All @@ -105,6 +116,11 @@ pub async fn lit_action(
Err(e) => return Err(anyhow::anyhow!("Actions failed with : {:?}", e).into()),
};

// Record execution against the key's rolling spend counter (no-op unless the
// key has a spend cap). Off the response path — the local update is in-memory
// and the lit-payments write is fire-and-forget.
admission.record_seconds(exec_start.elapsed().as_secs_f64().ceil() as u64);

let response = match serde_json::from_str::<serde_json::Value>(&result.response) {
Ok(response) => response,
Err(e) => {
Expand Down
1 change: 1 addition & 0 deletions lit-api-server/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::utils::{parse_with_hash::pkp_id_to_h160, u256_to_derviation_path};
pub mod account_management;
pub mod core_features;
pub mod eip712;
pub mod spending_rules;
pub mod v1;

pub async fn pkp_id_to_derviation_path(api_key: &str, pkp_id: &str) -> Result<String, String> {
Expand Down
Loading