Skip to content

Commit f3fcc50

Browse files
glitch003claude
andcommitted
feat(lit-api-server): enforce per-key spending rules on the hot path (lambda-parity PR 2)
Gateway side of the Lambda-parity work. Reads the on-chain hasSpendingRules gate in the same multicall as the execute-permission check; keys without rules pay zero extra work. - accounts: can_execute_action_with_spending_rules -> (can_execute, has_spending_rules), cached in BlockchainCache (new execute_and_spending entry, same generation invalidation). Reads the new view via a scoped sol! interface (read_only_client_and_address helper) so it tracks the workspace alloy version instead of the regenerated giant binding. - core::spending_rules: flag-gated enforcer. On a flagged key it fetches rules (cached, TTL) from lit-payments /internal, then enforces a rolling spend cap (402), a per-node token-bucket rate limit (429), and a per-node concurrency cap (429). Spend is recorded off the response path (in-memory + fire-and- forget POST). Inert unless LIT_PAYMENTS_INTERNAL_URL + INTERNAL_SERVICE_TOKEN are set AND the key's on-chain flag is on. - Wired into core_features::lit_action + the /lit_action route + main state. - 429 (too_many_requests) ApiStatus helper. Origin allowlist (P2.1) deferred. SWR background refresh for the rules cache is a marked follow-up (currently TTL with inline cold-miss fetch). cargo check clean; 4 unit tests for the token bucket, window reset, and hash format. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f185c7c commit f3fcc50

9 files changed

Lines changed: 565 additions & 2 deletions

File tree

lit-api-server/src/accounts/blockchain_cache.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub struct BlockchainCache {
3535
use_wallet: Cache<String, bool>,
3636
/// `can_execute_action_and_use_wallet` results.
3737
execute_and_wallet: Cache<String, (bool, bool)>,
38+
/// `can_execute_action_with_spending_rules` results: (canExecute, hasSpendingRules).
39+
execute_and_spending: Cache<String, (bool, bool)>,
3840
/// `get_wallet_derivation` results.
3941
wallet_derivation: Cache<String, U256>,
4042
/// Per-account generation counter keyed by the string representation of
@@ -62,6 +64,11 @@ impl BlockchainCache {
6264
.time_to_idle(ttl)
6365
.time_to_live(ttl)
6466
.build();
67+
let execute_and_spending = Cache::builder()
68+
.max_capacity(MAX_CAPACITY)
69+
.time_to_idle(ttl)
70+
.time_to_live(ttl)
71+
.build();
6572
let wallet_derivation = Cache::builder()
6673
.max_capacity(MAX_CAPACITY)
6774
.time_to_idle(ttl)
@@ -71,6 +78,7 @@ impl BlockchainCache {
7178
execute_action,
7279
use_wallet,
7380
execute_and_wallet,
81+
execute_and_spending,
7482
wallet_derivation,
7583
generations: RwLock::new(HashMap::new()),
7684
}
@@ -112,6 +120,13 @@ impl BlockchainCache {
112120
format!("{h}:g{g}:ew:{cid_hash}:{wallet:#x}")
113121
}
114122

123+
/// Build a cache key for `can_execute_action_with_spending_rules`.
124+
pub fn execute_and_spending_key(&self, api_key_hash: U256, cid_hash: U256) -> String {
125+
let h = api_key_hash.to_string();
126+
let g = self.generation(&h);
127+
format!("{h}:g{g}:es:{cid_hash}")
128+
}
129+
115130
/// Build a cache key for `get_wallet_derivation`.
116131
pub fn wallet_derivation_key(&self, api_key_hash: U256, wallet: Address) -> String {
117132
let h = api_key_hash.to_string();
@@ -134,6 +149,11 @@ impl BlockchainCache {
134149
&self.execute_and_wallet
135150
}
136151

152+
/// Reference to the `can_execute_action_with_spending_rules` cache.
153+
pub fn execute_and_spending_cache(&self) -> &Cache<String, (bool, bool)> {
154+
&self.execute_and_spending
155+
}
156+
137157
/// Reference to the `get_wallet_derivation` cache.
138158
pub fn wallet_derivation_cache(&self) -> &Cache<String, U256> {
139159
&self.wallet_derivation

lit-api-server/src/accounts/mod.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,67 @@ pub async fn can_execute_action(api_key: &str, cid_hash: U256) -> Result<bool> {
676676
Ok(can_execute)
677677
}
678678

679+
/// Scoped binding for the spending-rules view added in lambda-parity PR 3.
680+
/// Defined here (not via the giant generated binding) so it tracks the workspace
681+
/// alloy version directly; fold into the generated binding once it is
682+
/// regenerated on the canonical toolchain. See `plans/chipotle-lambda-parity.md`.
683+
mod spending_view {
684+
alloy::sol! {
685+
#[sol(rpc)]
686+
contract SpendingView {
687+
function canExecuteActionWithSpendingRules(
688+
uint256 apiKeyHash,
689+
uint256 cidHash
690+
) external view returns (bool canExecute, bool hasSpendingRules);
691+
}
692+
}
693+
}
694+
695+
async fn fetch_execute_and_spending(
696+
account_api_key_hash: U256,
697+
cid_hash_eth: U256,
698+
) -> Result<(bool, bool)> {
699+
let (client, address) = crate::accounts::signable_contract::read_only_client_and_address()?;
700+
let contract = spending_view::SpendingView::new(address, client);
701+
let result = contract
702+
.canExecuteActionWithSpendingRules(account_api_key_hash, cid_hash_eth)
703+
.call()
704+
.await?;
705+
Ok((result.canExecute, result.hasSpendingRules))
706+
}
707+
708+
/// Combined hot-path check: `(can_execute, has_spending_rules)` in a single RPC.
709+
///
710+
/// `has_spending_rules` is the zero-latency gate for per-key Lambda-parity
711+
/// controls — false for every key that never set it, so the common path does no
712+
/// extra work. See `plans/chipotle-lambda-parity.md`.
713+
#[instrument(
714+
name = "accounts::can_execute_action_with_spending_rules",
715+
level = "debug",
716+
skip_all,
717+
err
718+
)]
719+
pub async fn can_execute_action_with_spending_rules(
720+
api_key: &str,
721+
cid_hash: U256,
722+
) -> Result<(bool, bool)> {
723+
let account_api_key_hash = api_key_hash(api_key);
724+
let cid_hash_eth = cid_hash;
725+
726+
if let Some(cache) = blockchain_cache::get() {
727+
let key = cache.execute_and_spending_key(account_api_key_hash, cid_hash);
728+
return cache
729+
.execute_and_spending_cache()
730+
.try_get_with(key, async move {
731+
fetch_execute_and_spending(account_api_key_hash, cid_hash_eth).await
732+
})
733+
.await
734+
.map_err(|e: Arc<anyhow::Error>| anyhow::anyhow!("{:#}", e));
735+
}
736+
737+
fetch_execute_and_spending(account_api_key_hash, cid_hash_eth).await
738+
}
739+
679740
#[instrument(
680741
name = "accounts::can_use_wallet_in_action",
681742
level = "debug",

lit-api-server/src/accounts/signable_contract.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,28 @@ pub async fn get_admin_api_signer() -> Result<SigningClient> {
9393
signer_provider(wallet)
9494
}
9595

96+
/// Read-only provider + the AccountConfig address, for ad-hoc scoped `sol!`
97+
/// interfaces that target functions not yet present in the regenerated giant
98+
/// binding (e.g. the spending-rules view from lambda-parity PR 3). Tracks the
99+
/// workspace alloy version directly; fold callers into the generated binding
100+
/// once it is regenerated on the canonical toolchain.
101+
pub(crate) fn read_only_client_and_address() -> Result<(SigningClient, Address)> {
102+
let client = GLOBAL_READ_ONLY_CLIENT
103+
.get()
104+
.ok_or_else(|| {
105+
anyhow::anyhow!(
106+
"Read-only client not initialised — call init_chain_clients() at startup"
107+
)
108+
})?
109+
.clone();
110+
let node_config = GLOBAL_NODE_CONFIG
111+
.get()
112+
.ok_or_else(|| anyhow::anyhow!("Node configuration not found"))?;
113+
let account_config_address =
114+
Address::from_slice(&hex_to_bytes(&node_config.contract_address)?);
115+
Ok((client, account_config_address))
116+
}
117+
96118
pub(crate) async fn get_read_only_account_config_contract() -> Result<AccountConfigInstance> {
97119
let client = GLOBAL_READ_ONLY_CLIENT
98120
.get()

lit-api-server/src/core/core_features.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
use crate::accounts::can_execute_action;
1+
use crate::accounts::can_execute_action_with_spending_rules;
22
use crate::accounts::chain_config::{ChainConfig, ConfigKeys};
3+
use crate::core::spending_rules::SpendingRulesState;
34
use crate::actions::client::ClientBuilder;
45
use crate::actions::client::models::DenoExecutionEnv;
56
use crate::actions::client::{
@@ -32,6 +33,7 @@ pub async fn lit_action(
3233
http_client: &reqwest::Client,
3334
chain_config: Arc<ChainConfig>,
3435
stripe_state: Option<Arc<StripeState>>,
36+
spending: &SpendingRulesState,
3537
lit_action_request: Json<LitActionRequest>,
3638
) -> Result<LitActionResponse, ApiStatus> {
3739
let request_id = request_span.request_id.clone();
@@ -49,7 +51,10 @@ pub async fn lit_action(
4951
)
5052
.await?;
5153
let cid_hash = ipfs_cid_to_u256(&derived_ipfs_id)?;
52-
let can_execute = can_execute_action(api_key, cid_hash)
54+
// Single multicall returns the execute permission AND the zero-latency
55+
// spending-rules gate. has_spending_rules is false for almost every key, so
56+
// the spending-rules path below is skipped entirely for them.
57+
let (can_execute, has_spending_rules) = can_execute_action_with_spending_rules(api_key, cid_hash)
5358
.instrument(tracing::debug_span!("lit_action::can_execute_action"))
5459
.await?;
5560
if !can_execute {
@@ -59,6 +64,11 @@ pub async fn lit_action(
5964
return Err(ApiStatus::forbidden(msg));
6065
}
6166

67+
// Enforce per-key spending rules (rolling cap / rate / concurrency) before
68+
// execution. Inert unless the key is flagged AND enforcement is configured.
69+
// The returned admission holds any concurrency permit until end of scope.
70+
let admission = spending.admit(api_key, has_spending_rules).await?;
71+
6272
// Cache after authorization so unauthorized requests cannot pollute the cache.
6373
ipfs_cache
6474
.insert(derived_ipfs_id.clone(), Arc::new(code_to_run.clone()))
@@ -96,6 +106,7 @@ pub async fn lit_action(
96106
action_ipfs_id: Some(derived_ipfs_id),
97107
};
98108

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

119+
// Record execution against the key's rolling spend counter (no-op unless the
120+
// key has a spend cap). Off the response path — the local update is in-memory
121+
// and the lit-payments write is fire-and-forget.
122+
admission.record_seconds(exec_start.elapsed().as_secs_f64().ceil() as u64);
123+
108124
let response = match serde_json::from_str::<serde_json::Value>(&result.response) {
109125
Ok(response) => response,
110126
Err(e) => {

lit-api-server/src/core/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::utils::{parse_with_hash::pkp_id_to_h160, u256_to_derviation_path};
33
pub mod account_management;
44
pub mod core_features;
55
pub mod eip712;
6+
pub mod spending_rules;
67
pub mod v1;
78

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

0 commit comments

Comments
 (0)