# Authentication Model Source: https://developer.litprotocol.com/architecture/authModel How accounts, API keys, PKPs, groups, and the TEE root key compose into a programmable KMS — and how self-sovereign vs SaaS emerges from configuration. ## Core Insight There are no "modes." Self-sovereign vs SaaS is not a toggle — it's an emergent property of how you configure the same system. The only things that vary are: 1. **Who is the Account Owner** (TEE-derived wallet from Stytch auth vs SAFE vs raw EOA) 2. **What scopes the API keys have** (everything vs execute-only vs somewhere in between) A "self-sovereign" setup is just: owner = SAFE, API keys = execute-only. A "SaaS" setup is just: owner = TEE-derived wallet, API keys = broad scopes. The contracts don't know or care. *** ## Entities ### Account The top-level identity. Just an address on Base. This address is the **owner** — it can do everything, and it's the only thing that can do certain structural and destructive operations. The owner address can be: * An EOA (simple, but no recovery) * A TEE-derived wallet (SaaS happy path — user authenticates via Stytch with email/passkey/OAuth, TEE verifies the Stytch auth token and derives a deterministic wallet from the authenticated user ID) * A SAFE or any governance contract (self-sovereign — multisig, voting, timelocks, whatever) The contracts don't distinguish between these. `msg.sender == owner` is `msg.sender == owner`. ### API Key A wallet keypair registered on-chain under an Account. The user holds the private key and sends it to the TEE over HTTPS. The TEE derives the address and looks up what scopes it has. **API keys have scopes.** A scope is a permission granted by the Account Owner when the key is registered (or updated). There are seven scopes: | Scope | What it allows | Scoped to | | --------------------- | --------------------------------------------- | ------------ | | `execute` | Invoke Lit Actions with PKPs | Per-group | | `pkp:create` | Create new PKPs in the account's PKP registry | Account-wide | | `group:create` | Create new groups | Account-wide | | `group:delete` | Delete groups | Account-wide | | `group:manageActions` | Add and remove action CIDs in a group | Per-group | | `group:addPkp` | Add PKP references to a group | Per-group | | `group:removePkp` | Remove PKP references from a group | Per-group | A key can have any combination of scopes. The owner grants scopes at registration time and can update them later. **Four of the seven scopes are per-group.** When you grant an API key `execute`, `group:manageActions`, `group:addPkp`, or `group:removePkp`, you specify *which groups* it applies to. This is the key security property — a leaked onboarding key that has `group:addPkp(group_1)` can only add PKPs to group\_1, not to some new insecure group that someone just created. And because it only has `group:addPkp` (not `group:removePkp`), it can't pull existing PKPs out of the group either. **Three scopes are account-wide.** `pkp:create` is account-wide because PKPs are created in the account's registry before being assigned to any group. Creating a PKP doesn't grant access to anything by itself — the PKP still has to be added to a group (which requires `group:addPkp` on that specific group) and someone has to have `execute` on that group to actually use it. `group:create` and `group:delete` are account-wide because groups aren't scoped to other groups. In SaaS mode, developers need these to build their app through the API. In self-sovereign mode, you simply don't grant these scopes to any API key, and then only the SAFE can create or delete groups. **Everything else is owner-only:** adding/revoking API keys, updating scopes, transferring ownership. These are structural operations that affect the trust boundary itself. ### PKP Registry (Account-level) An on-chain list of PKP derivation path IDs owned by the Account. PKPs are created here, then referenced by Groups. The actual key material (signing key + symmetric encryption key) only exists transiently inside the TEE, derived on-demand from the root key using the derivation path ID. It's never persisted, never leaves the TEE boundary. ### Lit Action Immutable JS code pinned to IPFS, identified by its CID. Actions are **not owned by anyone** — they're public, content-addressed code. Any account can reference any CID in its groups. There is no action registry. This is intentional. Actions are meant to be reusable across companies and users. An ecosystem of audited, well-known action CIDs that people drop into their groups is more valuable than everyone registering private copies. Think of them like npm packages — you reference them, you don't own them. ### Group The core authorization primitive. A Group is a **permission policy** that binds together: * A set of **PKP references** (derivation path IDs from the account's PKP registry) * A set of **Action CIDs** (any valid IPFS CID — no registration required) That's it. A group is just `{PKPs, Actions}`. "Who can execute" is not a property of the group — it's a property of API key scopes. The owner decides which API keys get `execute` on which groups when they configure the keys. A Group answers the question: "Can this action use this key?" The API key scope answers: "Can this caller use this group?" Groups are owned by the Account. Only the owner can create or delete them. PKPs can be referenced by multiple groups. The same action CID can appear in groups across completely unrelated accounts. ### Root Key The master secret managed by Phala's KMS. Access is governed by on-chain governance at the infrastructure level — only approved TEE build images can derive from it. This is the Phala/dstack layer, separate from the per-account auth model described here. *** ## How the Pieces Fit Together ``` Account (owner address) │ ├── API Keys (each with scopes) │ ├── key_dev: [execute(*), pkp:create, group:create, ← SaaS: broad scopes │ │ group:delete, group:manage*(*)] │ ├── key_server: [execute(group_1)] ← self-sovereign: execution only │ └── key_onboard: [pkp:create, group:addPkp(group_1)] ← self-sovereign: onboarding │ ├── PKP Registry │ ├── pkp_001 (derivation path) │ ├── pkp_002 │ └── pkp_003 │ └── Groups ├── group_1 │ ├── PKPs: [pkp_001, pkp_002] ← must be in your PKP registry │ └── Actions: [QmABC..., QmDEF...] ← any IPFS CID │ └── group_2 ├── PKPs: [pkp_002, pkp_003] └── Actions: [QmGHI...] ← could be the same CID another company uses ``` Note: there is no "authorized callers" list on the group. Which keys can execute against a group is determined entirely by the `execute` scope on the API keys, managed by the owner. *** ## Execution Flow (inside the TEE) 1. User sends HTTP request: API key (private key) + "run action QmABC with pkp\_001" 2. TEE derives address from private key 3. TEE reads on-chain: does this address have an API key with `execute` scope? On which groups? 4. TEE checks: is there a group this key can execute on where QmABC is a listed action AND pkp\_001 is a listed PKP? 5. If yes → derive pkp\_001 key material from root key → fetch QmABC from IPFS → execute in sandbox with access to key material → return result 6. If no → reject *** ## Management Flow (two paths to the same contracts) ### Path A: Via TEE (convenience relay) User sends HTTP + API key to the TEE. TEE checks the key's scopes (including which groups the scope applies to), then signs and submits a transaction to the permissions contracts on Base on the user's behalf. ``` User → HTTP + API key → TEE → tx → Permissions Contract (Base) ``` The user never interacts with the chain directly. This is the default for most users. ### Path B: Direct to chain The Account Owner (SAFE, EOA, whatever) submits transactions directly to the permissions contracts. The TEE is not involved in the mutation at all. ``` SAFE/EOA → tx → Permissions Contract (Base) ``` This is how self-sovereign users handle governance operations. It's transparent and auditable — the SAFE proposal is visible on-chain before execution. **Both paths write to the same contracts.** The contract authorization check is: ``` require( msg.sender == accountOwner || isAPIKeyWithScope(msg.sender, requiredScope, groupId) ) ``` *** ## Permission Matrix | Operation | Owner | API Key | Scope required | | ------------------------------- | ----- | ------- | ------------------------------- | | Invoke action + PKP | ✓ | ✓ | `execute(group_id)` | | Create PKP | ✓ | ✓ | `pkp:create` | | Create group | ✓ | ✓ | `group:create` | | Delete group | ✓ | ✓ | `group:delete` | | Add/remove action CIDs in group | ✓ | ✓ | `group:manageActions(group_id)` | | Add PKPs to group | ✓ | ✓ | `group:addPkp(group_id)` | | Remove PKPs from group | ✓ | ✓ | `group:removePkp(group_id)` | | Add API key | ✓ | ✗ | — (owner only) | | Revoke API key | ✓ | ✗ | — (owner only) | | Update API key scopes | ✓ | ✗ | — (owner only) | | Transfer ownership | ✓ | ✗ | — (owner only) | *** ## Why Self-Sovereign Emerges from Configuration Consider two setups: ### Setup A: "SaaS mode" * **Owner:** TEE-derived wallet (user authenticates via Stytch, TEE derives wallet from authenticated user ID) * **API key `dev_key`:** scopes = `[execute(*), pkp:create, group:create, group:delete, group:manageActions(*), group:addPkp(*), group:removePkp(*)]` * **Effect:** The developer can do everything via HTTP calls except manage API keys and transfer ownership (those are owner-only, done through the Stytch-authenticated dashboard). This is the full development experience — create groups, add actions, create PKPs, execute, iterate. Fast iteration, no multisig overhead. Recovery is via Stytch re-authentication — the TEE will derive the same wallet from the same user ID. When the app is production-ready, the developer can revoke the broad-scoped `dev_key` and replace it with purpose-built keys that have narrower scopes. ### Setup B: "Self-sovereign mode" * **Owner:** A 3-of-5 SAFE * **API key `server_key`:** scopes = `[execute(group_1)]` — can only run actions on group\_1 * **API key `onboard_key`:** scopes = `[pkp:create, group:addPkp(group_1)]` — can create PKPs and add them to group\_1 only * **No API keys with `group:create`, `group:delete`, `group:manageActions`, or `group:removePkp` scopes** * **Effect:** Day-to-day operations flow through purpose-built API keys. The server can execute actions. The onboarding server can set up new customers without a SAFE vote. But everything structural — creating or deleting groups, adding or swapping action CIDs, removing PKPs — always requires a SAFE multisig vote, because that's the whole point: transparent, auditable governance over what code your PKPs can run and how your groups are organized. If `onboard_key` leaks, the attacker can create PKPs and add them to group\_1 (which is annoying but not catastrophic — they're just creating new key material that uses the same already-audited actions). They can't remove existing PKPs from the group, can't add PKPs to any other group, can't change actions, can't create or delete groups, can't execute anything. An evil admin with `server_key` can invoke existing actions but cannot change the rules. *** ## Upgrade Flow Example: Swapping Actions in a Group ### In SaaS mode (Setup A) 1. Pin new Lit Action JS to IPFS → get `QmNEW` 2. `POST /groups/:id/actions` with `{add: ["QmNEW"], remove: ["QmOLD"]}` → TEE submits tx to update group 3. Done. Next execution request uses new permissions. `dev_key` has `group:manageActions(*)`, so this is allowed on any group. ### In self-sovereign mode (Setup B) 1. Pin new Lit Action JS to IPFS → get `QmNEW` 2. Propose a SAFE transaction batch: * `group.addAction(groupId, "QmNEW")` * `group.removeAction(groupId, "QmOLD")` 3. SAFE signers review the new code (CID is deterministic — they can fetch and audit it from IPFS) 4. 3-of-5 signers approve → SAFE executes batch tx directly on the permissions contracts 5. Done. Next execution request reads updated on-chain state. TEE was not involved in the upgrade at all. This is intentional. No API key has `group:manageActions` in Setup B, so the only path to change actions is the owner going direct to chain. Every action upgrade is a visible, auditable SAFE proposal. *** ## Onboarding Flow Example (Setup B) 1. `POST /pkps/create` using `onboard_key` → TEE submits tx, new PKP registered in account's PKP registry 2. `POST /groups/group_1/pkps` with `{add: ["pkp_new"]}` using `onboard_key` → TEE submits tx 3. Done. New customer has a PKP in group\_1. The existing `server_key` (which has `execute(group_1)`) can now execute actions with this PKP on the customer's behalf. This is the key reason `onboard_key` exists in Setup B — you don't want to hit the SAFE every time you onboard a new user. Creating PKPs and adding them to a pre-configured group is a high-volume, low-risk operation. The API keys are per-purpose, not per-customer: one `server_key` executes for all customers, one `onboard_key` handles all onboarding. The SAFE only needs to be involved for structural changes: which actions are trusted, which groups exist, and who gets API keys. *** ## Account Lifecycle ### Onboarding (SaaS) 1. User signs up via Stytch (email, passkey, Google OAuth, etc.) 2. TEE verifies the Stytch auth token and derives a deterministic wallet from the authenticated user ID → this becomes the Account Owner address 3. System registers this on-chain as a new Account 4. Owner creates a first group and a first API key with broad scopes 5. User uses this API key for all HTTP interactions ### Graduating to self-sovereign 1. User deploys a SAFE on Base with their desired signer set 2. User calls `transferOwnership(safeAddress)` — authenticated via Stytch (current owner) through the TEE relay 3. Account Owner is now the SAFE 4. SAFE creates new API keys with restricted scopes, locked to specific groups (e.g. `server_key`, `onboard_key`) 5. SAFE revokes the old broad-scoped API key 6. Stytch is fully out of the loop — the SAFE is sovereign ### Key rotation Owner registers a new API key address with the desired scopes, then revokes the old one. In SaaS mode, this is an HTTP call (TEE derives the owner wallet from Stytch auth and signs the tx). In self-sovereign mode, this requires a SAFE vote. *** # Entity Relationships Source: https://developer.litprotocol.com/architecture/diagram Entity relationships, on-chain vs TEE boundaries, and how self-sovereign vs SaaS emerges from configuration — not modes. **Core Insight:** There are no "modes." Self-sovereign vs SaaS is an **emergent property** of how you configure the same system. The only things that vary are **who is the Account Owner** (TEE-derived wallet vs SAFE vs EOA) and **what scopes the API keys have**. The contracts don't know or care. *** ## Entity Boundaries The system spans four distinct trust boundaries. Understanding what lives where is essential to reasoning about security. ### User / External | Entity | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Account Owner** | Top-level identity. An address on Base that can do everything. Can be an EOA, TEE-derived wallet (Stytch auth), or SAFE/governance contract. **Ultimate authority.** | | **API Key (Private Key)** | User holds the private key locally. Sent to TEE over HTTPS per-request. TEE derives the address and checks scopes on-chain. | | **SAFE / Governance** | Optional. Multisig, timelocks, voting. Submits txs directly to chain for structural changes — TEE not involved. | ### TEE Enclave (Phala / dstack) | Entity | Description | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | **Root Key** | Master secret managed by Phala's KMS. Only approved TEE build images can derive from it. Never leaves the enclave. | | **Key Derivation** | Signing key + symmetric encryption key derived transiently from root key using derivation path ID. Never persisted. | | **Auth Verification** | Derives address from API key → reads on-chain scopes → checks group membership of action CID + PKP → allows or rejects. | | **Sandbox Execution** | Fetches Lit Action from IPFS, runs in sandboxed JS environment with access to derived key material. Returns result to caller. | | **TX Relay** | Convenience relay for management operations. TEE checks scopes, then signs and submits tx to Base on user's behalf. | ### On-Chain (Base) | Entity | Description | | -------------------- | --------------------------------------------------------------------------------------------------------- | | **Account Contract** | Registers the owner address. All permissions flow from this. Requires `msg.sender == owner`. | | **API Key Registry** | On-chain mapping of key addresses → scopes. Includes per-group scope bindings. Owner-managed. | | **PKP Registry** | List of PKP derivation path IDs owned by the account. PKPs are created here, then referenced by Groups. | | **Groups** | Permission policies binding `{PKP IDs, Action CIDs}`. "Who can execute" is on the API key, not the group. | ### IPFS (Content-addressed) | Entity | Description | | --------------- | ---------------------------------------------------------------------------------------------------------------------- | | **Lit Actions** | Immutable JS on IPFS, referenced by CID. Not owned by anyone — public, reusable, content-addressed. Like npm packages. | *** ## Entity Relationships ``` USER / EXTERNAL ON-CHAIN (BASE) TEE ENCLAVE ───────────────────────────── ────────────────────────── ────────────────────────────── Account Owner Account Contract Root Key EOA / SAFE / TEE-derived ──▶ owner address registered master secret, never exported │ owns │ derives │ API Key Registry Auth + Key Derivation API Key (private key) ──▶ address → scopes mapping ◀── verify scopes, derive keys Held by user, sent/request │ provides keys │ sent over HTTPS │ reads │ └──────────────────────────▶ TEE Sandbox Execution │ runs Lit Actions w/ key material PKP Registry │ fetched from IPFS derivation path IDs ▼ │ referenced Lit Actions (IPFS) Groups ◀── Immutable JS, public CIDs {PKP refs, ACIDs} │ CID ref TX Relay signs + submits mgmt txs ``` *** ## Execution Flow (Inside the TEE) | Step | Action | Who | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | | **1** | User sends API key (private key) + `"run action QmABC with pkp_001"` over HTTPS | `user → tee` | | **2** | TEE derives the public address from the provided private key | `inside tee` | | **3** | TEE reads the API Key Registry on Base — does this address have `execute` scope? On which groups? | `tee → chain read` | | **4** | TEE checks: is there a group this key can execute on where **QmABC is a listed action** AND **pkp\_001 is a listed PKP**? | `tee → chain read` | | **5** | If authorized → derive pkp\_001 key material from root key → fetch QmABC from IPFS → execute in sandbox with key material access → return result | `inside tee + ipfs fetch` | | **✕ Reject** | If any check fails → reject the request. No key material is derived. | — | *** ## Management Paths There are two paths for making structural changes (creating groups, adding PKPs, updating scopes): ### Path A: Via TEE Relay *(convenience)* TEE checks scopes and submits the transaction on the user's behalf. ``` User + API Key → TEE (verify scopes) → Permissions Contract ``` ### Path B: Direct to Chain *(self-sovereign)* Owner submits transactions directly — TEE is not involved. ``` SAFE / EOA → Permissions Contract ``` *** ## API Key Scopes | Scope | Allows | Type | | --------------------- | ----------------------------------------- | ------------ | | `execute` | Invoke Lit Actions with PKPs | per-group | | `pkp:create` | Create new PKPs in the account's registry | account-wide | | `group:create` | Create new groups | account-wide | | `group:delete` | Delete groups | account-wide | | `group:manageActions` | Add / remove action CIDs in a group | per-group | | `group:addPkp` | Add PKP references to a group | per-group | | `group:removePkp` | Remove PKP references from a group | per-group | *** ## Permission Matrix | Operation | Owner | API Key | Scope Required | | ---------------------- | :---: | :-----: | ------------------------------- | | Invoke action + PKP | ✓ | ✓ | `execute(group_id)` | | Create PKP | ✓ | ✓ | `pkp:create` | | Create group | ✓ | ✓ | `group:create` | | Delete group | ✓ | ✓ | `group:delete` | | Add/remove actions | ✓ | ✓ | `group:manageActions(group_id)` | | Add PKPs to group | ✓ | ✓ | `group:addPkp(group_id)` | | Remove PKPs from group | ✓ | ✓ | `group:removePkp(group_id)` | | Add / revoke API key | ✓ | ✕ | owner only | | Update API key scopes | ✓ | ✕ | owner only | | Transfer ownership | ✓ | ✕ | owner only | *** ## Configuration Comparison The same system, two very different security postures — determined entirely by who the owner is and what scopes the keys carry. ### SaaS Mode *Fast iteration, broad scopes, Stytch recovery* | | | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Owner** | TEE-derived wallet from Stytch auth (email / passkey / OAuth) | | **API Key** | `dev_key` with all scopes: `execute(*)`, `pkp:create`, `group:create`, `group:delete`, `group:manageActions(*)`, `group:addPkp(*)`, `group:removePkp(*)` | | **Effect** | Developer does everything via HTTP. Only API key management and ownership transfer require Stytch-authenticated dashboard. Recovery = Stytch re-auth. | ### Self-Sovereign Mode *Auditable governance, restricted scopes, SAFE multisig* | | | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | **Owner** | 3-of-5 SAFE multisig on Base | | **API Keys** | `server_key` → `execute(group_1)` only. `onboard_key` → `pkp:create` + `group:addPkp(group_1)` only. No structural scopes granted. | | **Effect** | Day-to-day ops via purpose-built keys. All structural changes (groups, actions, PKP removal) require SAFE vote. Leaked key blast radius is minimal. | # Groups Source: https://developer.litprotocol.com/architecture/groups How groups organize wallets, actions, and usage keys in Lit Chipotle. ## What is a Group? A **group** is the core organizing unit in Lit Chipotle. It binds together three things: 1. **Wallets (PKPs)** — which wallets can be used 2. **IPFS Actions** — which lit-actions can be executed 3. **Usage API Keys** — which keys have access (via their permission arrays) Think of a group as an access-control boundary: a usage API key can only run actions and use wallets that belong to groups it has been granted access to. ``` ┌─────────────────────────┐ │ Group 1 │ │ │ Usage Key A ─────────►│ Wallet X Action CID │ (execute_in: [1]) │ Wallet Y Action CID │ │ │ └─────────────────────────┘ ┌─────────────────────────┐ │ Group 2 │ │ │ Usage Key B ─────────►│ Wallet Z Action CID │ (execute_in: [1,2]) │ │ │ │ └─────────────────────────┘ ``` In this example, Key A can only use Group 1's wallets and actions. Key B can use both groups. The account key always has full access to all groups. ## Why Groups Exist Without groups, every usage key would have access to every wallet and every action in your account. Groups let you: * **Scope a key to a single dApp** — give your price-oracle service a key that can only execute the price-oracle action using a specific wallet. * **Isolate environments** — separate staging actions from production actions. * **Rotate access safely** — revoke a usage key without affecting other keys or groups. ## How Groups Connect to Everything | Resource | Relationship to Group | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------- | | **Wallet (PKP)** | Added via `add_pkp_to_group`. A wallet can belong to multiple groups. | | **IPFS Action** | Added via `add_action_to_group` (raw CID, server hashes it). An action can belong to multiple groups. | | **Usage API Key** | Granted access at creation via permission arrays (e.g., `execute_in_groups: [1, 2]`). Use `[0]` as a wildcard for all groups. | | **Account Key** | Always has full access to all groups — no group scoping needed. | ## Common Patterns ### One group per dApp ``` Group "Price Oracle" → wallet-A, action-QmPriceOracle Group "NFT Minter" → wallet-B, action-QmMintNFT ``` Give each dApp its own usage key scoped to its group. If the price-oracle key leaks, the minter is unaffected. ### All-access key for development Create a usage key with `execute_in_groups: [0]` (wildcard). This key can run any action in any group — useful for local development, but never deploy it. ### Shared wallets across groups A single wallet can belong to multiple groups. This is useful when multiple dApps need to sign with the same address but run different actions. ## Group Lifecycle 1. **Create** — `POST /core/v1/add_group` with a name and optional pre-permitted PKPs and CID hashes. 2. **Configure** — Add wallets (`add_pkp_to_group`) and actions (`add_action_to_group`). 3. **Grant access** — Create or update usage keys with the group ID in their permission arrays. 4. **Update** — `POST /core/v1/update_group` to change name, description, or permission lists. 5. **Delete** — `POST /core/v1/remove_group` to remove the group. Usage keys that referenced it lose that access. ## Permission Flags on Groups When creating a group, two convenience flags control default access: * **All wallets permitted** — any wallet in the account can be used via this group (no need to add individually). * **All actions permitted** — any registered action can be run via this group. These are set in the Dashboard's group creation form or via the `pkp_ids_permitted` and `cid_hashes_permitted` arrays in the API. On-chain, these flags are *not* separate booleans: they are encoded using wildcard values in the arrays: * To permit **all wallets**, include the zero PKP ID in `pkp_ids_permitted`: * `pkp_ids_permitted: ["0x0000000000000000000000000000000000000000000000000000000000000000"]` * To permit **all actions**, include `0` in `cid_hashes_permitted`: * `cid_hashes_permitted: [0]` Leaving these arrays empty or omitting them does **not** mean "all" — it means no wallets/actions are automatically permitted by default. ## Further Reading * [API Reference](/management/api_direct) — Full endpoint docs for group management * [API Keys](/management/api_keys) — How usage keys connect to groups * [Architecture](/architecture/index) — System design overview # Overview Source: https://developer.litprotocol.com/architecture/index How Lit Chipotle's three composable layers — TEE enclave, on-chain permissions, and IPFS — work together to provide programmable key management. Lit Chipotle is built on three composable layers that each handle a distinct concern. Understanding the separation makes it easier to reason about security, auditability, and where your own code fits in. ## The Three Layers **TEE Enclave (Phala / dstack)** The enclave holds the root key and performs all sensitive operations: key derivation, authorization checking, and sandboxed Lit Action execution. Nothing that touches key material ever leaves the enclave. The TEE also acts as a convenience relay — it can sign and submit on-chain management transactions on your behalf after verifying your API key scopes. **On-Chain Permissions (Base)** All authorization state lives on-chain in a set of smart contracts: an Account contract that registers the owner address, an API Key Registry mapping key addresses to scopes, a PKP Registry of wallet derivation path IDs, and Groups that bind PKPs to permitted action CIDs. The TEE reads these contracts to decide whether to execute a request. You can update them either through the TEE relay or by submitting transactions directly from an EOA or multisig. **Lit Actions (IPFS)** Lit Actions are immutable JavaScript programs stored on IPFS and referenced by content ID (CID). They are not owned by anyone — they are public, reusable, and content-addressed, similar to npm packages. The TEE fetches the action by CID at execution time and runs it inside a sandboxed JS environment that has access to the derived key material. ## Self-Sovereign vs SaaS There are no modes. Whether you operate in a self-sovereign or SaaS posture is an emergent property of who owns the Account contract and what scopes your API keys carry. | | SaaS | Self-Sovereign | | --------------------------- | ------------------------------------ | -------------------------------------- | | **Account Owner** | TEE-derived wallet (Stytch auth) | 3-of-5 SAFE multisig on Base | | **API Key Scopes** | All scopes — full access via HTTP | Purpose-built keys with minimal scopes | | **Structural Changes** | Via TEE relay (Stytch-authenticated) | SAFE vote → direct on-chain tx | | **Key Recovery** | Stytch re-authentication | SAFE signers | | **Leaked Key Blast Radius** | High | Minimal — scoped to specific groups | ## Further Reading * [Verify the TEE in 30 seconds](/architecture/verification/quick-verify) — one-click Phala Trust Center report for the live API * [Auth Model & Permission Matrix](/architecture/authModel) — detailed entity boundaries, execution flow, and the full permission matrix * [System Diagram](/architecture/diagram) — entity relationships, on-chain vs TEE boundaries, and management paths * [Security & Verification](/architecture/verification/index) — Zero-Trust TLS, attestation verification, and the full chain of trust * [On-Chain KMS](/architecture/verification/onchain-kms) — how Base smart contracts gate key release # Chain of Trust Reference Source: https://developer.litprotocol.com/architecture/verification/chain-of-trust What each verification layer checks and why it matters — application, platform, network, and governance. The sections below explain what each verification step is actually checking and why it matters. For the step-by-step commands, see the [Full Verification Guide](/architecture/verification/full-verification). For the canonical reference, see [Phala: Complete Chain of Trust](https://docs.phala.com/phala-cloud/attestation/chain-of-trust). ## Application Layer | Check | What it proves | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **compose-hash** | The SHA-256 of the `app-compose.json` config (which includes `docker-compose.yaml` plus metadata) is recorded as an event in RTMR3. Recomputing the hash from `/info` and comparing it to the attested value proves the CVM is running the declared configuration. | | **Docker image digests** | All images (lit-actions, lit-api-server, otel-collector, dstack-ingress) use `@sha256:` digest pinning. Mutable tags like `:latest` would allow silent image substitution. | | **Image provenance (Sigstore)** | Each image is signed with Sigstore cosign (keyless, GitHub OIDC). Verification proves the image was built by GitHub Actions from the `LIT-Protocol/chipotle` repository and recorded in the public Rekor transparency log. | | **RTMR3 event log replay** | Each event extends RTMR3 via a hash chain: `RTMR3_new = SHA384(RTMR3_old ‖ SHA384(event))`. The dstack-verifier replays all events from the initial value (48 zero bytes) and confirms the final value matches the RTMR3 in the TDX quote. This ensures no events were added, removed, or modified. | ## Platform Layer The TDX quote contains hardware-measured registers that attest the entire boot chain: | Register | What it measures | | --------- | ----------------------------------------------------- | | **MRTD** | Virtual firmware hash | | **RTMR0** | Hardware configuration | | **RTMR1** | Kernel measurements | | **RTMR2** | Boot parameters | | **RTMR3** | Application events (compose-hash, key-provider, etc.) | | Check | What it proves | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **TDX quote signature** | Intel signs the TDX quote with the hardware root of trust. Verification against the Intel root CA proves the quote came from genuine TDX hardware. The dstack-verifier and the Phala Cloud API both handle this. | | **OS measurements** | MRTD, RTMR0–2 values must match known-good values from the dstack release. The **DstackKms** contract on-chain whitelists allowed OS images via `allowedOsImages(bytes32)`. | | **KMS identity** | The `key-provider` event in RTMR3 records the KMS root CA public key hash. This binds the CVM to a specific trusted KMS — if someone substituted a rogue KMS, this hash would change and RTMR3 verification would fail. The DstackKms contract also whitelists KMS instances via `kmsAllowedAggregatedMrs(bytes32)`. | | **KMS attestation** | The KMS itself runs in a TEE with its own attestation quote. [Phala's Trust Center](https://github.com/Phala-Network/trust-center) verifies this independently. | ## Network Layer | Check | What it proves | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Evidence files** | dstack-ingress serves `/evidences/` with `cert-.pem`, `sha256sum.txt`, `acme-account.json`, and `quote.json`. The SHA-256 of `sha256sum.txt` is embedded in the evidence quote's `reportData`, creating a checksum chain from the TDX hardware root of trust to the certificate files. | | **Evidence checksum chain** | `SHA-256(sha256sum.txt)` must match `reportData` in `/evidences/quote.json`. This proves the evidence files were generated inside the TEE that produced the quote. | | **Certificate fingerprint match** | The leaf cert DER fingerprint from `openssl s_client` must match the leaf cert extracted from the evidence PEM. This proves the live TLS connection uses the same certificate attested by the TEE. | | **CAA DNS records** | The custom domain has a CAA CNAME alias pointing to the gateway domain (`_.dstack-base-prod5.phala.network`), which restricts certificate issuance to Let's Encrypt via DNS-01 with a specific ACME account URI — ensuring only the TEE-controlled ACME flow can obtain certificates. | ## Governance Layer | Check | What it proves | | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **DstackApp contract** ([`0x3F91…05FfC`](https://basescan.org/address/0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC)) | The compose-hash must be whitelisted via `allowedComposeHashes(bytes32)` before the CVM will boot. Address matches the `app_id` returned by `GET /info`. | | **Phala KMS contract** ([`0x2f83…Ba9C`](https://basescan.org/address/0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C)) | Whitelists allowed OS images (`allowedOsImages`) and KMS instances (`kmsAllowedAggregatedMrs`). Only whitelisted versions can boot. | | **AccountConfig contract** | Governs Lit Chipotle's permission model on Base — account ownership, API key scopes, PKP registries, and action groups. | | **Safe multisig** | All three contracts above are administered by a Safe multisig ([`0xF688411c0FFc300cAb33EB1dA651DBb3E6891098`](https://basescan.org/address/0xF688411c0FFc300cAb33EB1dA651DBb3E6891098)) on Base. Production deployments use a two-phase CI workflow: propose → approve via Safe UI → execute. | # Full Verification Guide Source: https://developer.litprotocol.com/architecture/verification/full-verification Step-by-step commands to verify every layer of the Lit Chipotle chain of trust: hardware attestation, application code, TLS certificates, and on-chain governance. This is a complete, end-to-end how-to. It mirrors the CI workflow that runs on every Lit Chipotle deployment and covers all layers of the chain of trust. **New to TEE verification?** You don't need to understand every cryptographic detail. Each step below is a self-contained check you can copy-paste into your terminal. The commands will output PASS or FAIL. If all steps pass, you have cryptographic proof that your connection terminates in genuine, unmodified TEE hardware running authorized code. **Prerequisites:** `python3` (3.8+), `docker`, `openssl`, `dig`, and optionally [`cosign`](https://docs.sigstore.dev/cosign/system_config/installation/) and [`cast`](https://book.getfoundry.sh/getting-started/installation) (from Foundry). ## 1. Verify the TDX attestation quote (Platform) **What this checks:** Is the server running on real Intel TDX hardware? The TDX attestation quote is like a hardware-signed certificate of authenticity — Intel's chips sign a statement about what software is running, and this step verifies that signature is genuine. Fetch the attestation from the live API and run the official dstack verifier. This validates the Intel TDX quote signature (proving genuine hardware), replays the RTMR3 event log (proving no events were tampered with), and checks OS measurements. Save the Python script below as `fix-attestation-event-log.py`, then run the verification commands. ```python fix-attestation-event-log.py theme={null} #!/usr/bin/env python3 """Fix event log for dstack verifier: compute digests for runtime events with empty digest. The dstack guest-agent strips digests from runtime events (RTMR3, event_type 0x08000001) to reduce response size. The digest is deterministically derived as SHA384(event_type || ":" || event || ":" || payload), so it can be recomputed. The Docker verifier's serde parser rejects digest="", so we fill in the computed digest before calling the verifier. """ import hashlib import json import struct import sys DSTACK_RUNTIME = 0x08000001 def hex_to_bytes(s: str) -> bytes: return bytes.fromhex(s) if s else b"" def compute_digest(event: str, payload_hex: str) -> str: payload = hex_to_bytes(payload_hex) data = struct.pack(" None: attest_path = sys.argv[1] with open(attest_path) as f: d = json.load(f) events = json.loads(d["event_log"]) for e in events: if e.get("digest") == "" and e.get("event_type") == DSTACK_RUNTIME: e["digest"] = compute_digest(e.get("event", ""), e.get("event_payload", "")) d["event_log"] = json.dumps(events) q = d["quote"] q = q[2:] if isinstance(q, str) and q.startswith("0x") else q out = {"quote": q, "event_log": d["event_log"], "vm_config": d["vm_config"], "attestation": None} json.dump(out, sys.stdout, separators=(",", ":")) if __name__ == "__main__": main() ``` ```bash Verification commands theme={null} # 1. Fetch attestation from the live API curl -sf https://api.chipotle.litprotocol.com/attestation > attestation.json # 2. Fix empty digests in runtime events (see note below) python3 fix-attestation-event-log.py attestation.json > verify-request.json # 3. Run the official dstack verifier docker run --rm -v $(pwd):/verify -w /verify --platform linux/amd64 \ dstacktee/dstack-verifier:latest --verify /verify/verify-request.json # 4. Check result python3 -c ' import json v = json.load(open("verify-request.json.verification.json")) print("VALID" if v.get("is_valid") else "INVALID") ' ``` The dstack guest-agent strips digests from RTMR3 runtime events to reduce response size. The fix script recomputes them as `SHA384(event_type || ":" || event || ":" || payload)`. The Docker verifier's parser rejects empty digests, so this preprocessing step is necessary. If you prefer not to run the Docker verifier locally, you can verify the TDX quote signature via the Phala Cloud API: ```bash theme={null} # Extract the raw quote hex and verify via Phala Cloud QUOTE=$(python3 -c 'import json; q=json.load(open("attestation.json"))["quote"]; print(q[2:] if q.startswith("0x") else q)') curl -X POST https://cloud-api.phala.network/api/v1/attestations/verify \ -H "Content-Type: application/json" \ -d "{\"hex\": \"$QUOTE\"}" ``` **Why are there two TDX quotes?** This guide verifies two separate TDX quotes from the same CVM: * **`/attestation`** (Step 1) returns a fresh TDX quote for validating RTMR measurements and the software stack. Its `reportData` is unused (all zeros). * **`/evidences/quote.json`** (Step 3) returns a separate TDX quote generated by dstack-ingress, where `reportData` contains `SHA-256(sha256sum.txt)` — binding the TLS certificate checksums to the TEE hardware. Both quotes come from the same CVM and share the same RTMR values, but serve different verification purposes. ## 2. Verify the application code (Application) **What this checks:** Is the TEE running the exact code you expect, with no modifications? This step verifies the Docker images and their configuration match what was built in CI from the public GitHub repository. ```bash theme={null} # Fetch app info (compose hash + full app-compose config) curl -sf https://api.chipotle.litprotocol.com/info > info.json # 2a. Verify compose-hash: the SHA-256 of the app-compose.json config # (which includes docker-compose.yaml + metadata) is recorded in RTMR3. python3 -c ' import json, hashlib info = json.load(open("info.json")) app_compose = info["tcb_info"]["app_compose"] computed = hashlib.sha256(app_compose.encode()).hexdigest() recorded = info["compose_hash"] print(f"Computed: {computed}") print(f"Recorded: {recorded}") assert computed == recorded, "MISMATCH" print("compose-hash OK") ' # 2b. Verify all images use @sha256: digest pinning (no mutable tags) python3 -c ' import json, re info = json.load(open("info.json")) compose_yaml = json.loads(info["tcb_info"]["app_compose"])["docker_compose_file"] images = re.findall(r"image:\s*(.+)", compose_yaml) assert images, "No image directives found" for img in images: img = img.strip() pinned = "@sha256:" in img status = "OK" if pinned else "NOT PINNED" print(f" {img[:80]} {status}") assert pinned, f"Image is not digest-pinned: {img}" print("All images digest-pinned OK") ' ``` **Verify image provenance with Sigstore** — Each image is signed with cosign (keyless, GitHub OIDC) during CI. This proves the image was built by GitHub Actions from the `LIT-Protocol/chipotle` repo: ```bash theme={null} # Install cosign: https://docs.sigstore.dev/cosign/system_config/installation/ # Verify each Lit-owned image digest extracted from the compose config above cosign verify \ --certificate-identity-regexp "https://github.com/LIT-Protocol/chipotle/.*" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ @ ``` The `dstack-ingress` image is a third-party dependency from the [dstack project](https://github.com/Dstack-TEE/dstack). It is Sigstore-signed from the dstack GitHub org, not from Lit's. To verify it, use `--certificate-identity-regexp "https://github.com/Dstack-TEE/dstack/.*"` with the same OIDC issuer. See the [dstack documentation](https://github.com/Dstack-TEE/dstack) for details on their signing and release process. ## 3. Verify TLS terminates in the TEE (Network) **What this checks:** Was the TLS certificate generated inside the TEE? This confirms your encrypted connection goes directly into the secure hardware — no proxy or intermediary can see your traffic. Lit Chipotle uses [dstack-ingress](https://github.com/Dstack-TEE/dstack-examples/tree/main/custom-domain/dstack-ingress) for custom-domain TLS. Unlike the default dstack gateway (which embeds cert hashes directly in the CVM's boot-time TDX quote via `reportData`), dstack-ingress runs as an application container and generates a **separate** TDX evidence quote after obtaining the Let's Encrypt certificate. This evidence quote binds the certificate to TDX hardware through a checksum chain: 1. dstack-ingress obtains a Let's Encrypt cert via DNS-01 inside the TEE 2. It computes `SHA-256` of each evidence file (cert PEM, ACME account) → `sha256sum.txt` 3. It computes `SHA-256(sha256sum.txt)` and requests a TDX quote with this hash as `reportData` 4. The evidence files and quote are served at `/evidences/` on the custom domain ```bash theme={null} # 3a. Download evidence files from the dstack-ingress container curl -sf https://api.chipotle.litprotocol.com/evidences/sha256sum.txt > evidences-sha256sum.txt curl -sf https://api.chipotle.litprotocol.com/evidences/quote.json > evidences-quote.json # Download the attested cert PEM (filename includes the domain) CERT_FILE=$(curl -sf https://api.chipotle.litprotocol.com/evidences/ \ | grep -o 'href="cert-[^"]*\.pem"' | sed 's/href="//;s/"//') curl -sf "https://api.chipotle.litprotocol.com/evidences/$CERT_FILE" > evidences-cert.pem # 3b. Verify the evidence checksum chain # The quote's reportData must equal SHA-256(sha256sum.txt) python3 -c ' import hashlib, json # Compute SHA-256 of the sha256sum.txt file with open("evidences-sha256sum.txt", "rb") as f: computed = hashlib.sha256(f.read()).hexdigest() # Extract reportData from the evidence quote eq = json.load(open("evidences-quote.json")) report_data = eq.get("report_data", "") # reportData is the hash zero-padded to 64 bytes (128 hex chars) attested = report_data[:64] print(f"SHA-256(sha256sum.txt): {computed}") print(f"Evidence reportData: {attested}") assert computed == attested, "MISMATCH — evidence checksum chain broken" print("Evidence checksum chain OK") ' # 3c. Verify the live TLS cert matches the attested cert # Extract the leaf cert (DER) fingerprint from what your TLS handshake received LIVE_CERT_HASH=$(openssl s_client -connect api.chipotle.litprotocol.com:443 \ -servername api.chipotle.litprotocol.com /dev/null \ | openssl x509 -outform DER 2>/dev/null \ | openssl dgst -sha256 -hex 2>/dev/null | awk '{print $NF}') # Extract the leaf cert (DER) fingerprint from the evidence PEM EVIDENCE_CERT_HASH=$(openssl x509 -in evidences-cert.pem -outform DER 2>/dev/null \ | openssl dgst -sha256 -hex 2>/dev/null | awk '{print $NF}') echo "Live TLS cert hash: $LIVE_CERT_HASH" echo "Evidence cert hash: $EVIDENCE_CERT_HASH" if [ "$LIVE_CERT_HASH" = "$EVIDENCE_CERT_HASH" ]; then echo "TLS certificate matches evidence — OK" else echo "MISMATCH: the served certificate does not match the attested evidence" fi # 3d. Verify CAA DNS records restrict certificate issuance # dstack-ingress sets a CAA CNAME alias on the custom domain pointing to the # gateway domain, which holds the actual CAA records restricting issuance to # Let's Encrypt with DNS-01 validation and a specific ACME account URI. echo "" echo "CAA alias on custom domain:" dig CAA api.chipotle.litprotocol.com +short echo "Resolved CAA policy:" dig CAA dstack-base-prod5.phala.network +short # 3e. Verify the ACME account URI matches the CAA allowlist # The CAA records include `accounturi=` restrictions. Verify the ACME account # used by this CVM matches one of the allowed accounts. curl -sf https://api.chipotle.litprotocol.com/evidences/acme-account.json > evidences-acme-account.json ACME_URI=$(python3 -c 'import json; a=json.load(open("evidences-acme-account.json")); print(a.get("uri", a.get("account_uri", "")))') echo "" echo "ACME account URI from TEE: $ACME_URI" echo "Allowed accounts in CAA:" dig CAA dstack-base-prod5.phala.network +short | grep accounturi ``` The CAA records on the gateway domain restrict certificate issuance to specific ACME account URIs. Verify that the ACME account URI from `/evidences/acme-account.json` matches one of the `accounturi=` values in the CAA records. If they don't match, it means this CVM's ACME account is not authorized by the DNS policy to obtain certificates for this domain. ## 4. Verify on-chain governance (Governance) **What this checks:** Was the code running in the TEE authorized through on-chain governance? The compose-hash (a fingerprint of the entire application configuration) must be registered in a smart contract on Base before the CVM will accept it. This means deploying new code requires an on-chain transaction — you can audit the full history on Basescan. The compose-hash must be registered in the **DstackApp** smart contract on Base before the CVM will accept it. A separate **Phala KMS** contract whitelists allowed OS images and KMS instances. You can inspect both on [Basescan](https://basescan.org). **Finding the DstackApp contract address:** The DstackApp contract is the on-chain governance contract that authorizes what code the CVM can run. To find the correct address for a given CVM: 1. Go to the [Phala Cloud dashboard](https://cloud.phala.network) and look up the application by its `app_id` (available from `GET /info`). The dashboard shows the associated DstackApp contract address. 2. Verify the contract is the one your CVM is attached to by checking the `app_id` in the `/info` response matches the app registered in the contract on Basescan. For Lit Chipotle production, the DstackApp contract is [`0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC`](https://basescan.org/address/0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC) (matches the `app_id` returned by `GET /info`). The Phala KMS contract is [`0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C`](https://basescan.org/address/0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C). ```bash theme={null} # Requires `cast` from Foundry (https://book.getfoundry.sh) # Derive the DstackApp contract address from the CVM's app_id DSTACK_APP=$(python3 -c 'import json; print("0x" + json.load(open("info.json"))["app_id"])') echo "DstackApp: $DSTACK_APP" # Check if the compose-hash is whitelisted in DstackApp COMPOSE_HASH=$(python3 -c 'import json; print("0x" + json.load(open("info.json"))["compose_hash"])') echo "Compose hash: $COMPOSE_HASH" cast call "$DSTACK_APP" "allowedComposeHashes(bytes32)" "$COMPOSE_HASH" --rpc-url https://mainnet.base.org # A return value of 0x...01 (true) means the compose-hash is whitelisted. # If it returns 0x...00 (false), the CVM is running unauthorized code. ``` **Governance via Safe multisig:** All on-chain governance actions are controlled by a Safe multisig ([`0xF688411c0FFc300cAb33EB1dA651DBb3E6891098`](https://basescan.org/address/0xF688411c0FFc300cAb33EB1dA651DBb3E6891098)) on Base. This Safe administers: * **DstackApp** ([`0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC`](https://basescan.org/address/0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC)) — compose-hash whitelisting for the Lit Chipotle CVM * **Phala KMS** ([`0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C`](https://basescan.org/address/0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C)) — KMS configuration and allowed OS images * **AccountConfig** — *details coming soon* Any governance action (e.g., whitelisting a new compose-hash, updating allowed OS images, or upgrading the AccountConfig Diamond) requires Safe signer approval. Production deployments use a two-phase workflow: CI proposes the transaction to the Safe, and signers approve it through the Safe UI before the deployment can proceed. # Security & Verification Source: https://developer.litprotocol.com/architecture/verification/index How to verify that your connection to Lit Chipotle terminates inside a genuine TEE running unmodified code. The Lit Chipotle API server runs inside an Intel TDX [Trusted Execution Environment](https://www.intel.com/content/www/us/en/developer/tools/trust-domain-extensions/overview.html) on [Phala Cloud](https://phala.com/), with its keys gated by smart contracts on Base. This section explains how that works and how to verify it yourself. One-click Phala Trust Center report plus three commands you can paste into a terminal to confirm the API is running unmodified code on real Intel TDX hardware. ## Zero-Trust TLS **What is Zero-Trust TLS?** In traditional web hosting, you trust the server operator not to inspect or tamper with your traffic. Zero-Trust TLS (ZT-TLS) eliminates this trust assumption entirely. The TLS private key is generated **inside** the Trusted Execution Environment (TEE) and **never leaves it** — not even the cloud provider, OS administrator, or Lit Protocol team can extract it. **How it works for Lit Chipotle:** 1. The [dstack-ingress](https://github.com/Dstack-TEE/dstack-examples/tree/main/custom-domain/dstack-ingress) container, running inside the CVM, generates a private key and requests a TLS certificate from Let's Encrypt via DNS-01 challenge (Route 53). The private key never leaves the TEE. 2. DNS [CAA records](https://letsencrypt.org/docs/caa/) for the domain restrict which CAs can issue certificates and require DNS-01 validation with a specific ACME account URI, ensuring only the TEE-controlled ACME flow can obtain a cert. 3. The certificate and ACME account are recorded as evidence files. Their SHA-256 checksums are hashed into a single digest that is embedded in a TDX attestation quote's `reportData` field. 4. Because the quote is signed by Intel's TDX hardware root of trust and bound to this digest, anyone can cryptographically prove the certificate was generated inside this specific TEE. **What this means:** When you connect to `api.chipotle.litprotocol.com` over HTTPS, the TLS handshake completes **inside the TEE**. No proxy, load balancer, or CDN can intercept the traffic. If the TLS handshake succeeds and the certificate is valid, you are provably talking to the TEE — not to any intermediary. **Contrast with traditional TLS:** Traditional TLS proves identity (the server holds the private key for this domain) but says nothing about *where* the key lives or *what code* uses it. ZT-TLS closes that gap: the key can only exist inside attested TEE hardware. Zero-Trust TLS means the encryption endpoint is the trust boundary. Once you verify the certificate was issued to the TEE, the HTTPS connection itself becomes your proof of confidentiality. For the full design, see [Phala: TEE-Controlled Domain Certificates](https://docs.phala.com/dstack/design-documents/tee-controlled-domain-certificates). ## Quick TLS Verification Given Zero-Trust TLS, simple certificate validation already gives strong guarantees. This is sufficient for most users. ```bash theme={null} # Inspect the TLS certificate openssl s_client -connect api.chipotle.litprotocol.com:443 \ -servername api.chipotle.litprotocol.com /dev/null \ | openssl x509 -noout -fingerprint -sha256 -dates -subject ``` * The certificate is issued by **Let's Encrypt** (a public CA) to the exact domain. * Because ZT-TLS guarantees the private key lives only in the TEE, a valid TLS handshake = connection to the TEE. * For programmatic clients: **pin the certificate fingerprint** after initial verification (see [Certificate Pinning](#certificate-pinning) below). ## Certificate Pinning Once full verification passes, pin the TLS certificate fingerprint: 1. Record the SHA-256 fingerprint from the verification above. 2. On subsequent connections, validate the cert matches the pinned fingerprint — this is fast and doesn't require re-running attestation. 3. **When to re-verify**: After any CVM redeployment, a new TLS cert is generated inside the TEE. Re-run the [full verification](/architecture/verification/full-verification) and update your pinned fingerprint. ## What's Next * [Verify in 30 Seconds](/architecture/verification/quick-verify) — Phala Trust Center one-click report plus three terminal commands * [On-Chain KMS](/architecture/verification/onchain-kms) — how Base smart contracts gate key release and how to confirm the KMS is active * [Full Verification Guide](/architecture/verification/full-verification) — step-by-step commands to verify every layer of the chain of trust * [Chain of Trust Reference](/architecture/verification/chain-of-trust) — detailed explanation of what each verification step checks and why it matters ## Further Reading * [Phala Trust Center for Lit Chipotle](https://trust.phala.com/app/3f91deaf16ff7c823ee65081d6bafa1ceea05ffc) — live verification report for our production app * [Phala: Trust Center Verification](https://docs.phala.com/phala-cloud/attestation/trust-center-verification) — how the Trust Center works * [Phala: Attestation Overview](https://docs.phala.com/phala-cloud/attestation/overview) * [Phala: Understanding On-Chain KMS](https://docs.phala.com/phala-cloud/key-management/understanding-onchain-kms) * [Phala: Zero-Trust TLS (TEE-Controlled Domain Certificates)](https://docs.phala.com/dstack/design-documents/tee-controlled-domain-certificates) * [Phala: Complete Chain of Trust](https://docs.phala.com/phala-cloud/attestation/chain-of-trust) * [Phala: Verify Your Application](https://docs.phala.com/phala-cloud/attestation/verify-your-application) * [Phala: Get Attestation](https://docs.phala.com/phala-cloud/attestation/get-attestation) * [RTMR3 Calculator](https://rtmr3-calculator.vercel.app/) — web tool for computing compose hashes * [dstack Verification Script](https://github.com/Dstack-TEE/dstack-examples/blob/main/attestation/rtmr3-based/verify.py) — reference Python implementation * [Phala Trust Center (reference implementation)](https://github.com/Phala-Network/trust-center) * [Sigstore cosign](https://docs.sigstore.dev/cosign/signing/overview/) # On-Chain KMS Source: https://developer.litprotocol.com/architecture/verification/onchain-kms How Lit Chipotle's root keys are gated by smart contracts on Base — not by Phala or Lit — and how to verify the KMS is active and configured correctly. Most "cloud KMS" services let the cloud provider authorize key release. Phala's [On-Chain KMS](https://docs.phala.com/phala-cloud/key-management/understanding-onchain-kms) replaces that backend with a smart contract on a public blockchain. The KMS will only release keys to a CVM whose attestation matches what's whitelisted on-chain — and Lit Protocol cannot change the whitelist unilaterally. This page explains what's on-chain, how to read it, and what "active" looks like. ## Why On-Chain KMS Without on-chain KMS, a TEE provider's backend decides which CVMs get keys. That's one trusted party. With on-chain KMS: * **No central authority.** A smart contract is the only thing that can authorize key release. Phala's backend does not sign transactions on Lit's behalf. * **Public, auditable governance.** Every code-version whitelist change is a Base transaction. Anyone can read the history on Basescan. * **Multi-party control.** The contract owner is a Safe multisig of Lit signers — no single party (including Lit) can change the whitelist alone. For the canonical design, see [Phala: Understanding On-Chain KMS](https://docs.phala.com/phala-cloud/key-management/understanding-onchain-kms) and [Cloud vs On-Chain KMS](https://docs.phala.com/phala-cloud/key-management/cloud-vs-onchain-kms). ## The contracts Two contracts on Base together gate key release for the Lit Chipotle CVM: ### Phala KMS — [`0x2f83…Ba9C`](https://basescan.org/address/0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C) A shared Phala contract that maintains the registry of KMS nodes, approved dstack OS images, and approved KMS aggregated measurements. It also acts as a factory for per-application `DstackApp` contracts. | Key state | What it means | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `allowedOsImages(bytes32)` | Whitelist of dstack OS image measurements (firmware + kernel + initrd hashes). The CVM's MRTD / RTMR0–2 must match a whitelisted image or boot is refused. | | `kmsAllowedAggregatedMrs(bytes32)` | Whitelist of KMS instance measurements. Pins the CVM to a specific trusted KMS. | | `owner()` | The Safe multisig that controls the whitelists. | ### DstackApp — [`0x3F91…05FfC`](https://basescan.org/address/0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC) The application-specific contract for Lit Chipotle. Its address is the `app_id` returned by `GET /info`. Anyone can confirm this matches: ```bash theme={null} curl -sf https://api.chipotle.litprotocol.com/info \ | python3 -c 'import json,sys; print("0x" + json.load(sys.stdin)["app_id"])' # Expect: 0x3f91deaf16ff7c823ee65081d6bafa1ceea05ffc ``` | Key state | What it means | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `allowedComposeHashes(bytes32)` | Whitelist of `app-compose.json` SHA-256 hashes. The TEE's RTMR3 must record a compose hash present here, or the KMS will not release keys. | | `allowedDeviceIds(bytes32)` | Whitelist of approved hardware device identifiers. | | `owner()` | The Safe multisig — only this address can add or remove compose hashes and device IDs. | ## How key release is gated The flow on every CVM boot: 1. The CVM generates an Intel TDX attestation quote covering its hardware, OS, and the compose hash of the code it loaded. 2. The KMS verifies the quote against Intel's root certificates. 3. The KMS reads the on-chain state: * Is the OS image in `Phala KMS.allowedOsImages`? * Is the KMS measurement in `Phala KMS.kmsAllowedAggregatedMrs`? * Is the compose hash in `DstackApp.allowedComposeHashes`? * Is the device ID in `DstackApp.allowedDeviceIds`? 4. **Only if all four pass** does the KMS release the keys this CVM is allowed to use. This means: even if Lit Protocol pushed a malicious Docker image, the CVM running it would not be able to obtain the root keys unless the new compose hash was first whitelisted on Base — which requires Safe signers to approve a transaction visible on Basescan. ## Confirming the KMS is active "Active" for on-chain KMS means three things hold simultaneously. You can verify each on Basescan or with `cast`. ### 1. The live compose hash is whitelisted ```bash theme={null} COMPOSE_HASH=0x$(curl -sf https://api.chipotle.litprotocol.com/info \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["compose_hash"])') cast call 0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC \ "allowedComposeHashes(bytes32)(bool)" "$COMPOSE_HASH" \ --rpc-url https://mainnet.base.org # Expect: true ``` A `true` here proves the currently-running code is authorized by on-chain governance. A `false` would mean the CVM is running code that the KMS would refuse to release keys to — which should never happen in production, because the CVM cannot boot without keys. ### 2. The DstackApp owner is the Safe multisig ```bash theme={null} cast call 0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC \ "owner()(address)" \ --rpc-url https://mainnet.base.org # Expect: 0xF688411c0FFc300cAb33EB1dA651DBb3E6891098 ``` This proves no single party can modify the compose-hash whitelist. The [Safe at `0xF688…1098`](https://app.safe.global/base:0xF688411c0FFc300cAb33EB1dA651DBb3E6891098) requires multiple signers to approve any change. ### 3. The CVM's app\_id matches the DstackApp address ```bash theme={null} APP_ID=$(curl -sf https://api.chipotle.litprotocol.com/info \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["app_id"])') echo "Live app_id: 0x$APP_ID" echo "Expected DstackApp: 0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC" ``` The two must match (case-insensitive). This proves the CVM you're talking to is actually attached to the on-chain governance contract you're auditing — not some other lookalike contract. ## Auditing the governance history Every governance action that changed the KMS configuration is a Base transaction. On the [DstackApp's Basescan page](https://basescan.org/address/0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC), the **Transactions** tab shows every `addComposeHash` and `removeComposeHash` call ever made. Each is a Safe execution requiring multiple signatures. To understand a specific deployment: 1. Find the deployment date in [Lit's release notes](https://github.com/LIT-Protocol/chipotle/releases) (or `git log`). 2. Find the `addComposeHash` transaction on Basescan around that date. 3. Open the Safe transaction (linked from the Basescan tx) — you can see which signers approved it. This is the same audit trail used by the [Phala Trust Center](https://trust.phala.com/app/3f91deaf16ff7c823ee65081d6bafa1ceea05ffc) when it shows the on-chain governance state. ## What's next * [Verify in 30 Seconds](/architecture/verification/quick-verify) — the Trust Center one-click report * [Full Verification Guide](/architecture/verification/full-verification) — replay the RTMR3 event log and check every layer * [Phala: Understanding On-Chain KMS](https://docs.phala.com/phala-cloud/key-management/understanding-onchain-kms) * [Phala: Cloud vs On-Chain KMS](https://docs.phala.com/phala-cloud/key-management/cloud-vs-onchain-kms) # Verify in 30 Seconds Source: https://developer.litprotocol.com/architecture/verification/quick-verify One click to confirm the Lit Chipotle API server is running unmodified code inside a genuine Intel TDX enclave — plus three commands to verify it yourself. When you call `api.chipotle.litprotocol.com`, your request terminates inside an Intel TDX [Trusted Execution Environment](https://www.intel.com/content/www/us/en/developer/tools/trust-domain-extensions/overview.html) (TEE) operated by [Phala Cloud](https://phala.com/). The TEE generates a hardware-signed attestation quote on every boot, and the code it's allowed to run is gated by smart contracts on Base — not by Lit Protocol or any Phala employee. The fastest way to verify this is to view the Phala Trust Center report for our production app. It runs every check on this page automatically and shows you the result in a browser. Phala's public Trust Center verifies the Intel TDX hardware quote, the Docker compose hash, the OS measurements, the TLS certificate, and the on-chain KMS configuration — all automatically, no install required. The Trust Center URL contains our `app_id` (`3f91deaf16ff7c823ee65081d6bafa1ceea05ffc`). This same value is returned by `GET /info` on the live API and is the address of the on-chain [DstackApp contract](https://basescan.org/address/0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC) that governs which code the enclave is allowed to run. ## What you should expect to see A passing Trust Center report confirms four independent properties: | Property | Why it matters | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Hardware quote verified** | Intel's TDX hardware root of trust signed a statement about what code is running. Anyone can verify the signature against Intel's published root CAs. | | **Compose hash matches** | The SHA-256 of `app-compose.json` (the docker-compose config + metadata) recorded in the TDX quote matches what's whitelisted in the on-chain DstackApp contract. | | **OS measurements match** | The boot-time measurements (firmware, kernel, initrd) match a known-good dstack OS release whitelisted in the Phala KMS contract. | | **TLS terminates in TEE** | The HTTPS certificate served by `api.chipotle.litprotocol.com` was generated inside the TEE itself. No proxy, load balancer, or CDN can see your traffic. | If any of these fail, the report will show it. ## Verify it yourself in three commands The Trust Center is convenient, but the whole point of attestation is that you don't have to trust *anyone* — including Phala. Here's the minimum-viable manual check: ```bash theme={null} # 1. What is the live API attesting to right now? curl -sf https://api.chipotle.litprotocol.com/info \ | python3 -m json.tool | head -20 # 2. Is the compose hash whitelisted on Base? # (Requires `cast` from Foundry — https://book.getfoundry.sh) COMPOSE_HASH=0x$(curl -sf https://api.chipotle.litprotocol.com/info \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["compose_hash"])') cast call 0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC \ "allowedComposeHashes(bytes32)(bool)" "$COMPOSE_HASH" \ --rpc-url https://mainnet.base.org # Expect: true # 3. Is the TLS cert pinned to the TEE-attested cert? LIVE=$(openssl s_client -connect api.chipotle.litprotocol.com:443 \ -servername api.chipotle.litprotocol.com /dev/null \ | openssl x509 -outform DER 2>/dev/null \ | openssl dgst -sha256 -hex | awk '{print $NF}') ATTESTED=$(curl -sf "https://api.chipotle.litprotocol.com/evidences/$( curl -sf https://api.chipotle.litprotocol.com/evidences/ \ | grep -o 'href="cert-[^"]*\.pem"' | sed 's/href="//;s/"//')" \ | openssl x509 -outform DER 2>/dev/null \ | openssl dgst -sha256 -hex | awk '{print $NF}') [ "$LIVE" = "$ATTESTED" ] && echo "TLS cert matches TEE evidence" || echo "MISMATCH" ``` For the full end-to-end verification — including replaying the RTMR3 event log, verifying image signatures with Sigstore, and walking the on-chain governance Safe — see the [Full Verification Guide](/architecture/verification/full-verification). ## On-chain governance you can audit Three smart contracts on Base together define what Lit Chipotle is allowed to do. All three are administered by a Safe multisig — no single party can change them. `0x3F91…05FfC` — whitelists the compose hashes (i.e. the docker-compose configurations) the Lit Chipotle CVM is allowed to boot. `0x2f83…Ba9C` — whitelists allowed dstack OS images and KMS instance measurements. Gatekeeps key release to the CVM. `0xF688…1098` — owns both contracts above. Any deployment or config change requires multiple Lit signers. What KmsAuth and DstackApp actually do, what "active" looks like on Basescan, and how key release is gated. ## What's next * [On-Chain KMS](/architecture/verification/onchain-kms) — how the KMS contracts gate key release and what to look for on Basescan * [Full Verification Guide](/architecture/verification/full-verification) — step-by-step manual verification of every layer * [Chain of Trust Reference](/architecture/verification/chain-of-trust) — what each layer checks and why * [Security & Verification Overview](/architecture/verification/index) — Zero-Trust TLS and the trust model # Quick Start Source: https://developer.litprotocol.com/index Get started with Lit Chipotle using the Lit Chipotle Dashboard or the REST API (Core SDK and cURL), the fastest and easiest method to get started with Lit Actions. This guide introduces the [**Lit Chipotle API**](https://api.chipotle.litprotocol.com/) and its web interface, the [**Lit Chipotle Dashboard**](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/) which allows both web3 and non-web3 developers to quickly set up their Lit environment and start using Lit Actions. ## Zero to LitAction 1. [Create an account via the Dashboard](/management/dashboard#1-request-a-new-account-or-log-in), note down your API key 2. [Add funds](/management/pricing#paying-with-a-credit-card-via-stripe) — click **Add Funds** in the Dashboard and pay with a credit card (minimum \$5.00). Running Lit Actions and metered/write management operations consume credits, while read-only management calls (for example listing resources or checking your balance) are free. 3. Add a usage API key - set its permissions by clicking "All Options" 4. Run a LitAction 1. [From the Dashboard](/management/dashboard#7-run-lit-actions) 2. [Programmatically via cURL/JavaScript](/management/api_direct#7-run-lit-action) 3. or build your own SDK from the [OpenAPI spec](/management/api_direct#open-api-specification) ## Overview The [**Lit Chipotle Dashboard**](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/) is the front end to the [**Lit Chipotle API**](https://api.chipotle.litprotocol.com) server, designed to quickly and efficiently execute Lit Actions and to manage your user accounts, usage parameters, client wallet creation (PKPs), and IPFS based actions, through a simple set of groups. \ \ Specifically it lets you: * Create accounts and obtain a master account API key. * Create **usage API keys** that can be scoped to specific lit-actions or used by dApps. * Create and manage **wallets (PKPs)** for signing and on-chain operations. * Register **IPFS CIDs** (immutable actions) and scope which wallets have access to them. * Organize all your resources into **groups** that combine PKPs, IPFS actions, and usage API keys in any combination. * Send **lit-actions** to the node for execution, authorized by a usage or account API key. All of this can be done via the [**Dashboard**](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/) (web GUI) or directly via the [**REST API**](https://api.chipotle.litprotocol.com), using a light-weight JS SDK, or even directly through cURL commands. Data is secure and on-chain—all configuration within the Dashboard can be achieved by talking directly to our contracts located on **BASE**. ## Using the Dashboard The [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/) is a web management GUI for Lit's Chipotle offering. Open it from your browser at `https://dashboard.chipotle.litprotocol.com/dapps/dashboard/` It supports light/dark theme for your convenience and provides simple management tools. **Dashboard workflow (recommended order):** 1. [Request a new account (or log in)](/management/dashboard#1-request-a-new-account-or-log-in) 2. [Add funds via credit card](/management/dashboard#2-add-funds) 3. [Request usage API keys](/management/dashboard#3-request-usage-api-keys) 4. [Request new PKPs (wallets)](/management/dashboard#4-request-new-pkps-wallets) 5. [Register IPFS CIDs (actions)](/management/dashboard#5-register-ipfs-cids-actions) 6. [Create groups](/management/dashboard#6-create-groups) 7. [Run lit-actions](/management/dashboard#7-run-lit-actions) For detailed step-by-step instructions with screenshots, see [Using the Dashboard](/management/dashboard). ## Using the API directly The same workflows can be done via the REST API under `/core/v1/`. All endpoints that require authentication expect the API key in a header (`X-Api-Key` or `Authorization: Bearer`). **API workflow:** 1. [New account or verify account (login)](/management/api_direct#1-new-account-or-verify-account-login) 2. [Add funds via credit card](/management/pricing#paying-with-a-credit-card-via-stripe) (or [via the billing API](/management/api_direct#billing)) 3. [Add usage API key](/management/api_direct#3-add-usage-api-key) 4. [Create wallet (PKP)](/management/api_direct#4-create-a-wallet-pkp) 5. [Add group and register IPFS action](/management/api_direct#5-add-group-and-register-ipfs-action) 6. [Add PKP to group (optional)](/management/api_direct#6-add-pkp-to-group-optional) 7. [Run lit-action](/management/api_direct#7-run-lit-action) For full code examples (JavaScript Core SDK and cURL), see the [API Reference](/management/api_direct). ## Daily Usage The dashboard is just your human-friendly configuration tool. Once your account and keys are set up to your liking, you can simply call the lit-action endpoint with your usage key each time you, your dApp or cron job needs to execute a lit action. So the only daily use step is 1. Call the API with your usage key, action-code ( or IPFS CID ) and any parameters that you need ## Further Reading * [API Mode vs ChainSecured Mode](/management/account_modes) — Picking an ownership model and migrating from API mode to ChainSecured * [API Keys](/management/api_keys) — Understanding account keys vs usage keys * [Pricing](/management/pricing) — Credit-based billing model * [Lit Actions](/lit-actions/index) — Writing and deploying Lit Actions * [Architecture](/architecture/index) — System design and auth model * [OpenAPI Spec](https://api.chipotle.litprotocol.com/core/v1/openapi.json) / [Swagger UI](https://api.chipotle.litprotocol.com/core/v1/swagger-ui) # Lit Actions SDK Source: https://developer.litprotocol.com/lit-actions/chipotle ### Table of Contents * [Welcome](#welcome) * [Encryption](#encryption) * [Encrypt](#encrypt) * [Parameters](#parameters) * [Decrypt](#decrypt) * [Parameters](#parameters-1) * [PKP Keys](#pkp-keys) * [getPrivateKey](#getprivatekey) * [Parameters](#parameters-2) * [getLitActionPrivateKey](#getlitactionprivatekey) * [getLitActionPublicKey](#getlitactionpublickey) * [Parameters](#parameters-3) * [getLitActionWalletAddress](#getlitactionwalletaddress) * [Parameters](#parameters-4) * [Action Utilities](#action-utilities) * [setResponse](#setresponse) * [Parameters](#parameters-5) * [Runtime Globals](#runtime-globals) * [LitActions](#litactions) * [ethers](#ethers) ## Welcome Welcome to the Lit Actions SDK Docs. These functions can be used inside a Lit Action. You should prefix each function with "Lit.Actions." so to call "encrypt()" you should do "Lit.Actions.encrypt()" To understand how these functions fit together, please view the [Quick Start guide](/index) and [Lit Actions overview](/lit-actions/index) ## Encryption Encryption and decryption functions. These functions are used to encrypt and decrypt data. The data is encrypted & decrypted using a symmetric key derived from the secret key of the PKP. ## Lit.Actions.Encrypt ### Parameters * `params` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** * `params.pkpId` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** The ID of the PKP * `params.message` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** The message to encrypt Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** The ciphertext ## Lit.Actions.Decrypt Decrypt data using AES with a symmetric key ### Parameters * `params` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** * `params.pkpId` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** The ID of the PKP * `params.ciphertext` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** The ciphertext to decrypt Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** The decrypted plaintext ## PKP Keys Key management functions for PKPs. These functions are used to get keys for PKPs that can be used in javascript functions. ## Lit.Actions.getPrivateKey Get the private key for a PKP wallet ### Parameters * `params` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** * `params.pkpId` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** The ID of the PKP Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** The private key secret ## Lit.Actions.getLitActionPrivateKey Get the private key for the currently executing Lit Action Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** The private key secret ## Lit.Actions.getLitActionPublicKey Get the public key for a Lit Action by IPFS ID ### Parameters * `params` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** * `params.ipfsId` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** The IPFS ID of the Lit Action Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** The public key ## Lit.Actions.getLitActionWalletAddress Get the wallet address for a Lit Action by IPFS ID ### Parameters * `params` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** * `params.ipfsId` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** The IPFS ID of the Lit Action Returns **[Promise](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** The wallet address ## Action Utilities Helpers available inside actions as `Lit.Actions.*`. ## Lit.Actions.setResponse Set the response returned to the client ### Parameters * `params` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** * `params.response` **any** The response to send to the client. If this is not a string, it will be JSON-encoded before being sent. A value of undefined is encoded as null. ## Runtime Globals Globals automatically available inside the Lit Action runtime. ## LitActions Global reference to the Lit Actions namespace for convenience. This alias is injected in the Lit Action execution environment and mirrors `Lit.Actions`. ## ethers The ethers.js v5 API exposed to Lit Actions for interacting with EVM chains. Includes wallets, providers, contracts, and cryptographic helpers. # Examples Source: https://developer.litprotocol.com/lit-actions/examples Seven common Lit Action patterns covering signing, encryption, decryption, HTTP fetching, contract calls, and sending ETH — including examples gated on live external data. Each example below is a self-contained Lit Action. Pass the code string to the `/core/v1/lit_action` endpoint with any required `js_params`. The `pkpId` parameter is the wallet address of the PKP you want to use, passed in via `js_params`. *** ## 1. Sign a Message The simplest pattern: retrieve a PKP's private key and sign an arbitrary message with it. The signature proves the message was attested by a specific, on-chain-registered key. ```javascript theme={null} // js_params: { pkpId, message } async function main({ pkpId, message }) { const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(message); return { message, signature }; } ``` The caller can verify the signature against the PKP's public key (or wallet address) to confirm the message originated from this action. *** ## 2. Encrypt a Secret Encrypt a sensitive string so that only the holder of the PKP can later decrypt it. Useful for storing API keys, passwords, or personal data on-chain or in IPFS without exposing the plaintext. ```javascript theme={null} // js_params: { pkpId, secret } async function main({ pkpId, secret }) { const ciphertext = await Lit.Actions.Encrypt({ pkpId, message: secret }); return { ciphertext }; } ``` Store the returned `ciphertext` anywhere — IPFS, a smart contract, a database — and retrieve the plaintext only when needed using the Decrypt action below. *** ## 3. Decrypt a Secret Decrypt a ciphertext that was previously produced by `Lit.Actions.Encrypt` using the same PKP. Only an action that is permitted to use the PKP (enforced on-chain) can decrypt it. ```javascript theme={null} // js_params: { pkpId, ciphertext } async function main({ pkpId, ciphertext }) { const plaintext = await Lit.Actions.Decrypt({ pkpId, ciphertext }); return { plaintext }; } ``` *** ## 4. Fetch a Crypto Price and Sign It Fetch the current price of ETH from a public API and sign the result. The caller receives both the price and a signature — a **verifiable price proof** that can be submitted to a smart contract as a trusted oracle update. ```javascript theme={null} // js_params: { pkpId } async function main({ pkpId }) { const res = await fetch( "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd" ); const data = await res.json(); const price = data?.ethereum?.usd; if (typeof price !== "number") { return { error: "Price fetch failed" }; } const payload = `ETH/USD: ${price}`; const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(payload); return { price, payload, signature }; } ``` A smart contract can call `ecrecover` on the signature to confirm the price was signed by a specific, known PKP address — without trusting any off-chain intermediary. *** ## 5. Gate a Signature on Live Weather Data Fetch live weather for a city using a decrypted API key and only sign a message if the temperature exceeds a threshold. Demonstrates combining decryption, an authenticated HTTP request, and conditional signing in one action. ```javascript theme={null} // js_params: { pkpId, city, minTempCelsius, message, encryptedWeatherApiKey } // Example: { pkpId: "0x...", city: "London", minTempCelsius: 20, message: "Approved", encryptedWeatherApiKey: "..." } async function main({ pkpId, city, minTempCelsius, message, encryptedWeatherApiKey }) { const apiKey = await Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedWeatherApiKey }); const res = await fetch( `https://api.openweathermap.org/data/2.5/weather?q=${city}&units=metric&appid=${apiKey}` ); const data = await res.json(); const temp = data?.main?.temp; if (typeof temp !== "number") { return { error: "Weather fetch failed" }; } if (temp < minTempCelsius) { return { signed: false, reason: `Temperature ${temp}°C is below threshold of ${minTempCelsius}°C` }; } const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(message); return { signed: true, temp, message, signature }; } ``` *** ## 6. Read from a Smart Contract Call a view function on an EVM smart contract and return the result. Useful for reading on-chain state (balances, governance votes, NFT ownership) inside an action, or for gating downstream logic on chain data. ```javascript theme={null} // js_params: { pkpId, contractAddress, holderAddress } // Checks the ERC-20 balance of holderAddress and signs the result. async function main({ pkpId, contractAddress, holderAddress }) { const rpcUrl = "https://mainnet.base.org"; const provider = new ethers.providers.JsonRpcProvider(rpcUrl); const erc20Abi = [ "function balanceOf(address owner) view returns (uint256)", "function symbol() view returns (string)", ]; const contract = new ethers.Contract(contractAddress, erc20Abi, provider); const [balance, symbol] = await Promise.all([ contract.balanceOf(holderAddress), contract.symbol(), ]); const balanceFormatted = ethers.utils.formatUnits(balance, 18); const payload = `${holderAddress} holds ${balanceFormatted} ${symbol}`; const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(payload); return { holder: holderAddress, balance: balanceFormatted, symbol, payload, signature }; } ``` *** ## 7. Send ETH to an Address Construct, sign, and broadcast an ETH transfer transaction from a PKP wallet. The PKP pays the gas and the transfer amount, so ensure the PKP wallet holds sufficient ETH on the target chain before running this action. ```javascript theme={null} // js_params: { pkpId, toAddress, amountEth, chainId, rpcUrl } // Example: { pkpId: "0x...", toAddress: "0x...", amountEth: "0.001", chainId: 8453, rpcUrl: "https://mainnet.base.org" } async function main({ pkpId, toAddress, amountEth, chainId, rpcUrl }) { const provider = new ethers.providers.JsonRpcProvider(rpcUrl); const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }), provider ); const tx = await wallet.sendTransaction({ to: toAddress, value: ethers.utils.parseEther(amountEth), chainId, }); const receipt = await tx.wait(); return { txHash: receipt.transactionHash, from: wallet.address, to: toAddress, amountEth, blockNumber: receipt.blockNumber, }; } ``` The PKP wallet at `pkpId` must hold enough ETH on the target chain to cover both the transfer amount and the gas fee. Use `createWallet` to get a PKP address, fund it on-chain, then use that address as `pkpId`. # Module Imports Source: https://developer.litprotocol.com/lit-actions/imports Lit Actions can now import third-party ESM packages directly from jsDelivr. Every import is pinned to an exact version and verified with SHA-384 integrity hashes before any code reaches the runtime. ## What Changed Until now, the only JavaScript available inside a Lit Action was what shipped with the runtime: `ethers` (the v5 version) and the `Lit.Actions` SDK. If you needed anything else, you had to inline it or bundle it into your action code before uploading to IPFS. That limitation is gone. Lit Actions can now **import ES modules at runtime** from [jsDelivr](https://www.jsdelivr.com/), a public CDN that serves npm packages as ready-to-run ESM. You write a standard `import` statement with a version-pinned package specifier and the runtime resolves it to jsDelivr, fetches, verifies, caches, and executes it. ```javascript theme={null} import { z } from "zod@3.22.4"; import { formatDistance } from "date-fns@3.6.0"; ``` This opens the door to thousands of npm packages: validation libraries, date/time utilities, encoding tools, math libraries, protocol implementations, and more. *** ## How It Works Every import goes through three stages before any bytes reach the V8 engine. ### 1. Resolution The runtime resolves the import specifier to a jsDelivr URL. You can write a short npm specifier (`zod@3.22.4`), an explicit ESM specifier (`zod@3.22.4/+esm`), or a full URL (`https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm`). When no file path is specified after the version, the runtime automatically appends `/+esm` to request the ESM entry point from jsDelivr. Bare package names without a version (`import { z } from "zod"`) and relative paths (`./util.js`) are rejected. Every import must include a pinned version. ### 2. Integrity Verification Each module URL is checked against an `integrity.lock` manifest that maps URLs to their expected SHA-384 hash. If the hash of the downloaded content does not match, the import fails and the action does not execute. For modules not yet in the manifest, the system uses **trust-on-first-use (TOFU)** with up to four-way verification: it fetches the module twice from jsDelivr, independently computes the SHA-384 of each response, verifies both against jsDelivr's SRI hash header when available, and if the import specifier includes an inline `#sha384-` hash, verifies against that as well. The module is accepted only if all checks agree. The verified hash is then pinned to the lockfile so all future fetches are verified against it. ### 3. Caching Once a module is verified, its source is held in an in-memory cache. Subsequent imports of the same URL (from any action execution) are served from cache without a network request. *** ## Import Syntax Imports use an npm-style specifier with a pinned version. The runtime automatically resolves these to jsDelivr URLs and appends `/+esm` when no file path is specified. ```javascript theme={null} // Import a specific named export import { z } from "zod@3.22.4"; // Import a default export import Ajv from "ajv@8.12.0"; // Import multiple named exports import { encode, decode } from "cbor-x@1.5.9"; // Scoped packages import { render } from "@preact/render-to-string@6.4.1"; // Specific file path (no /+esm auto-appended) import { format } from "date-fns@3.6.0/esm/index.js"; // Explicit /+esm still works (backward compatible) import { z } from "zod@3.22.4/+esm"; ``` The specifier format is: ``` @[/] ``` The `@` pin is required and ensures you always get the exact same bytes. The `/` part is optional. When omitted, `/+esm` is automatically appended to request the package's ESM entry point from jsDelivr. You can also specify a path to a specific file in the package. ### Inline Integrity Hash You can append a `#sha384-` fragment to any import specifier to declare the expected integrity hash directly in your code. The runtime will verify the fetched content against this hash before execution. ```javascript theme={null} import { z } from "zod@3.22.4#sha384-oKhMb3mCbOey4gFjFHm1YmKJF/WuNdbiLPSLHMwbkPE1mEpMJOoDQMHTcIltUJQ+"; ``` This makes integrity verification self-contained in the action code, with no dependency on an external lockfile. When an inline hash is provided, it takes priority over any entry in `integrity.lock`. The `/+esm` suffix is still auto-appended when no file path precedes the `#` fragment. The fragment is never sent over the network (per the URL specification). It is stripped before fetching and used only for local verification. ### Full URLs Full jsDelivr URLs are also accepted, with or without an inline hash: ```javascript theme={null} import { z } from "https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm"; import { z } from "https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm#sha384-oKhMb3m..."; ``` Always pin to an exact version (`@3.22.4`, not `@^3.22.4` or `@latest`). Unpinned versions can resolve to different code over time, which will cause integrity verification to fail and makes your action's behavior non-deterministic. *** ## Examples ### Validate Input with Zod ```javascript theme={null} import { z } from "zod@3.22.4"; // js_params: { pkpId, userData } async function main({ pkpId, userData }) { const UserSchema = z.object({ email: z.string().email(), age: z.number().int().min(0).max(150), name: z.string().min(1).max(100), }); const result = UserSchema.safeParse(userData); if (!result.success) { return { error: "Validation failed", issues: result.error.issues }; } const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(JSON.stringify(result.data)); return { validated: result.data, signature }; } ``` ### Format and Sign a Timestamp Proof ```javascript theme={null} import { format, utcToZonedTime } from "date-fns-tz@2.0.1"; // js_params: { pkpId, timezone } async function main({ pkpId, timezone }) { const now = new Date(); const zonedTime = utcToZonedTime(now, timezone); const formatted = format(zonedTime, "yyyy-MM-dd HH:mm:ss zzz", { timeZone: timezone }); const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(formatted); return { timestamp: formatted, timezone, signature }; } ``` ### Encode Data as CBOR Before Signing ```javascript theme={null} import { encode } from "cbor-x@1.5.9"; // js_params: { pkpId, payload } async function main({ pkpId, payload }) { const encoded = encode(payload); const hex = Array.from(new Uint8Array(encoded)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(hex); return { cbor: hex, signature }; } ``` ### JSON Schema Validation with AJV ```javascript theme={null} import Ajv from "ajv@8.12.0"; // js_params: { pkpId, data, schema } async function main({ pkpId, data, schema }) { const ajv = new Ajv(); const validate = ajv.compile(schema); if (!validate(data)) { return { error: "Schema validation failed", errors: validate.errors }; } const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(JSON.stringify(data)); return { valid: true, data, signature }; } ``` *** ## Package Compatibility Not every npm package works. The package must meet these requirements: | Requirement | Why | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Ships ESM in its npm tarball | jsDelivr serves files as-is from the published package. No transpilation or CJS-to-ESM conversion happens. | | No Node.js built-in dependencies | Lit Actions run in Deno/V8, not Node.js. Packages that import `fs`, `path`, `crypto`, or other Node built-ins will fail. | | No native/binary addons | The runtime is a sandboxed V8 isolate. Native code cannot execute. | | Pinned to an exact version | Required for integrity verification and deterministic behavior. | Most modern packages ship ESM. Some well-known examples that work: * **zod** — Schema validation * **ajv** — JSON Schema validation * **date-fns** / **date-fns-tz** — Date utilities * **cbor-x** — CBOR encoding/decoding * **uuid** — UUID generation * **lodash-es** — Utility functions (ESM build) * **preact** — Lightweight UI rendering (for server-side HTML generation) * **superstruct** — Structural validation Packages that will **not** work: * **axios** — depends on Node.js `http` module * **lodash** (non-ESM) — CJS only, use `lodash-es` instead * **sharp** — native binary addon * **bcrypt** — native binary addon If you are unsure whether a package ships ESM, check its `package.json` for an `"exports"` or `"module"` field, or test the jsDelivr URL directly in a browser: `https://cdn.jsdelivr.net/npm/@/+esm` *** ## Why jsDelivr We evaluated several CDN options for serving npm packages as ESM. The choice came down to a set of non-negotiable requirements for running third-party code inside a cryptographic signing environment. ### The Requirements 1. **Immutability** — The same URL must return the exact same bytes forever. If content can change, integrity hashes become meaningless. 2. **No server-side transformation** — The CDN must serve the original files from the npm tarball. Any server-side bundling or transpilation introduces a layer we cannot audit or pin. 3. **Version pinning** — URLs must support exact version locks (`@3.22.4`) so that the resolved content is deterministic. 4. **SRI hash support** — The CDN should support Subresource Integrity headers so hashes can be computed and verified against the original source. ### How jsDelivr Meets Them **jsDelivr with pinned versions** serves raw files directly from npm packages at version-pinned URLs that are guaranteed immutable. Once a version is published to npm, the content behind `https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm` never changes. jsDelivr does not perform any transformation, bundling, or minification on the source files. What the package author published to npm is exactly what gets served. Chipotle enforces this immutability guarantee by validating the SHA-384 hash of every module on every fetch, so even if a CDN were to serve altered content, the integrity check would catch it and reject the module before execution. jsDelivr also provides built-in SRI hash support and is backed by a multi-CDN infrastructure (Cloudflare, Fastly, and others) with high availability and global edge caching. ### Alternatives Considered | CDN | Verdict | Reason | | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **esm.sh** | Rejected | Performs server-side CJS-to-ESM conversion. The output is generated, not the original source. We cannot guarantee that two fetches of the same URL produce the same bytes, and we cannot audit the conversion logic. | | **unpkg.com** | Rejected | Serves raw npm files (good) but does not guarantee immutability of the `?module` rewriting layer. The redirects it uses also complicate integrity verification. | | **Skypack** | Rejected | Performs server-side optimization and conversion. Same concerns as esm.sh. | | **Self-hosted** | Deferred | Eliminates third-party trust entirely but requires operating a package mirror. May be considered for enterprise deployments in the future. | The key constraint is that the package must **already ship ESM** in its published npm tarball. jsDelivr does not convert CJS to ESM. Most modern packages do ship ESM, but older CJS-only packages will not work without a conversion step that happens before publishing. *** ## Security Model Module imports operate under the same security model as the rest of the Lit Actions runtime. Every module is verified before it reaches V8. | Layer | Protection | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **URL allowlist** | Only `https://cdn.jsdelivr.net/` is accepted. All other origins are rejected at the resolution stage. | | **Version pinning** | Exact versions are required. The URL is the immutable identifier. | | **SHA-384 integrity** | Every module is hashed and compared against the `integrity.lock` manifest or an inline `#sha384-` hash in the import specifier. Mismatches are fatal. | | **Trust-on-first-use** | New modules are double-fetched, hashes compared, and verified against jsDelivr's SRI header before being accepted and pinned. | | **No redirects** | HTTP redirects are blocked. The CDN must serve the content directly. | | **Size limits** | Responses larger than 10 MB are rejected. | | **Timeouts** | Fetch operations time out after 30 seconds. | | **Sandboxing** | Imported code runs in the same Deno/V8 sandbox as the rest of the action. No filesystem, no subprocess, no native code. | Imported packages run with the same permissions as your action code, including access to `Lit.Actions.getPrivateKey()` if a PKP is available. Only import packages you trust. The integrity system ensures the code has not been tampered with in transit, but it does not audit what the code does. *** ## Limits Module imports are subject to the same [resource limits](/lit-actions/limits) as the rest of your action: * **Memory** — Imported modules count toward the action's memory limit (default 128 MB). * **Timeout** — Module fetch time counts toward the action's execution timeout (default 15 minutes). * **Network** — Module fetches use a separate HTTP client from the action's `fetch()` API and do not count toward the per-action fetch limit. However, they share the same execution timeout. * **Module size** — Individual modules are capped at 10 MB. *** ## Debugging Imports Use `Lit.Actions.showImportDetails()` to inspect which modules were loaded and their integrity hashes. This is useful for debugging import resolution, verifying that the expected modules were fetched, and auditing the integrity of imported code. ```javascript theme={null} import { z } from "zod@3.22.4"; async function main() { const details = Lit.Actions.showImportDetails(); // details is an array of { url, hash } objects: // [ // { // "url": "https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm", // "hash": "sha384-oKhMb3mCbOey4gFjFHm1..." // } // ] return details; } ``` The import details are also written to the action's console log, so they appear alongside other `console.log` output in the response logs. *** ## Next Steps * [Examples](/lit-actions/examples) — More action patterns using the built-in SDK * [Patterns](/lit-actions/patterns) — Advanced patterns like gating logic and action-identity signing * [Lit Actions SDK](/lit-actions/chipotle) — Full API reference # Overview Source: https://developer.litprotocol.com/lit-actions/index Lit Actions are immutable JavaScript programs stored on IPFS and executed by the Lit network. They can sign data, encrypt and decrypt secrets, and make arbitrary HTTP requests — all in a verifiable, trustless way. ## What is a Lit Action? Lit Actions are immutable JavaScript programs stored on IPFS and executed by the Lit network. Each action is identified by its IPFS CID, which serves as both its address and an immutable commitment to its code. Once published to IPFS, an action's behavior can never be changed. Actions are executed on-node and have access to a set of [SDK functions](/lit-actions/chipotle) that let them: * **Sign data** using the private key of a Programmable Key Pair (PKP) * **Encrypt and decrypt** data using a symmetric key derived from a PKP * **Make HTTP requests** to external data sources (APIs, oracles, blockchains) * **Set a response** that is returned to the caller after execution Because actions run on the Lit network and results are signed by PKPs, the outputs they produce carry a cryptographic proof of their origin and integrity. ## Programmable Key Pairs (PKPs) A Programmable Key Pair (PKP) is a wallet — an elliptic-curve key pair — managed by the Lit network. An account can hold many PKPs and organize them into groups alongside permitted IPFS actions. When a Lit Action executes, it can retrieve the private key of a PKP using `Lit.Actions.getPrivateKey({ pkpId })` and use it (via [ethers.js](https://docs.ethers.org/v5/)) to sign transactions, messages, or any arbitrary data. Which actions are permitted to use which PKPs is enforced **on-chain** through the `AccountConfig` contract and managed via the [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/). ## What is a Proof? When a Lit Action retrieves data from an external source — for example, a price feed, a weather API, or another blockchain — and signs the result with a PKP, that signed output is called a **proof**. A proof answers the question: *"How do I know this data is authentic?"* Traditionally, when you send a transaction on Ethereum you sign it with your private key to prove you authorized it. A Lit Action proof works the same way: the data output is signed by a PKP to attest that it came from a specific, verifiable computation — not from an arbitrary off-chain script. ### Why proofs matter * **Trustless data ingestion** — Smart contracts cannot make HTTP requests. A Lit Action can fetch external data and deliver it on-chain with a signature that the contract can verify. * **Verifiable computation** — Any party can independently verify that the signed output was produced by the specific action code (identified by IPFS CID) and signed by the specific PKP (identified by its public key / token ID). * **No trusted intermediary** — Because the action code is immutable on IPFS and the signing key is managed by the Lit network, neither the action author nor any single node can forge a result. ## How Actions Fit into Chipotle In the Chipotle system, actions are organized within **groups**. A group ties together: * One or more **PKPs** (wallets available for signing inside the action) * One or more **IPFS CIDs** (the permitted action code) * **Usage API keys** scoped to run actions within that group This means access to both compute (which action runs) and key material (which PKP it can use) is controlled by a single on-chain configuration, managed through the Dashboard or directly via the `AccountConfig` smart contract on Base. ``` Account └── Group ├── PKP (wallet) ├── IPFS CID (action) └── Usage API Key ``` When you call the `/core/v1/lit_action` endpoint, the server validates that the API key is permitted to execute the submitted code against the requested PKP — before any execution begins. ## The Action Runtime Inside a Lit Action, the `Lit.Actions` namespace exposes the current SDK. Key functions include: | Function | Description | | -------------------------------------------- | ------------------------------------------------------------ | | `return value` (from `main`) | Return a value to the caller — the preferred response method | | `Lit.Actions.setResponse({ response })` | Legacy: set the response returned to the caller | | `Lit.Actions.getPrivateKey({ pkpId })` | Retrieve a PKP private key for signing | | `Lit.Actions.getLitActionPrivateKey()` | Retrieve this action's own identity key | | `Lit.Actions.Encrypt({ pkpId, message })` | Encrypt a string with a PKP-derived AES key | | `Lit.Actions.Decrypt({ pkpId, ciphertext })` | Decrypt ciphertext with a PKP-derived AES key | The `ethers` library (v5) is available as a global, making it straightforward to sign messages, construct transactions, or interact with any EVM chain. For the full API reference, see [Lit Actions SDK](/lit-actions/chipotle). ## A Minimal Example ```javascript theme={null} // Fetch ETH price from an API and sign it with a PKP. // pkpId is injected via js_params. async function main({ pkpId }) { const res = await fetch("https://api.example.com/eth-price"); const { price } = await res.json(); const wallet = new ethers.Wallet( await Lit.Actions.getPrivateKey({ pkpId }) ); const signature = await wallet.signMessage(`ETH price: ${price}`); return { price, signature }; } ``` The caller receives both the price and a signature that can be verified against the PKP's public key — a cryptographic proof that this specific action fetched and attested to this specific price. ## Next Steps * [Module Imports](/lit-actions/imports) — Import third-party npm packages from jsDelivr with integrity verification * [Examples](/lit-actions/examples) — Working code for common patterns * [Lit Actions SDK](/lit-actions/chipotle) — Full API reference for the current runtime * [Migration from Naga](/lit-actions/migration/changes) — Mapping deprecated Naga actions to current equivalents # Limits Source: https://developer.litprotocol.com/lit-actions/limits Default resource limits for Lit Actions on the Chipotle network. ## Limits The following limits apply to all Lit Action executions on Chipotle. They are designed to keep the network stable and fair for all users while covering the vast majority of real-world use cases. *** ### Code & Upload | Limit | Default | | --------------------------------- | ------- | | Maximum action code size (inline) | 16 MB | | Maximum IPFS action size | N/A | | Maximum `js_params` payload | 64 KB | Action code submitted inline via the API or Dashboard must not exceed 16 MB. Downloading actions via IPFS is not currently supported. If your action requires large static data, consider fetching it at runtime via `fetch` rather than bundling it into the action itself. *** ### Execution | Limit | Default | | ----------------------------------------- | ---------- | | Maximum execution time | 15 minutes | | Maximum memory | 64 MB | | Maximum outbound HTTP requests per action | 50 | | Maximum response payload size | 100 KB | | Maximum console log output | 100 KB | | Maximum key/signature requests per action | 10 | Actions that exceed the execution time limit are terminated and return a timeout error. Long-running workflows should be broken into smaller actions or offload heavy computation to an external service and fetch the result. *** ### Need Higher Limits? The defaults above are suitable for most development and production workloads. If your use case requires higher limits — more throughput, longer execution time, larger payloads, or increased concurrency — we're happy to discuss it. Reach out through any of the following: * **Email:** [support@litprotocol.com](mailto:support@litprotocol.com) * **Discord:** [litgateway.com/discord](https://litgateway.com/discord) * **Telegram:** Contact the team via the Lit Protocol Telegram channel When you reach out, include a brief description of your use case and the specific limits you need — this helps us respond quickly with the right configuration for your account. # Changes Source: https://developer.litprotocol.com/lit-actions/migration/changes # Lit Actions: Migration from Datil or Naga This document is a reference for developers migrating from the Naga Lit Actions SDK to the current SDK. It covers all actions available in both generations and explains how deprecated Naga actions map to current equivalents. ## Overview The current Lit Actions SDK is a streamlined runtime focused on cryptographic key operations and action identity. Many capabilities that previously required runtime API calls — permission checking, access control, multi-party signing coordination — are now handled **on-chain** through the security model managed by the [Dashboard application](https://developer.litprotocol.com/) or accessed directly on-chain. > **Deprecated actions** generally have equivalent functionality derived through the combination of **new actions** and the **on-chain security settings** provided by the Dashboard application (or accessed directly on-chain). > Permissions, group membership, and access control are now encoded at account-creation time rather than checked dynamically at execution time. *** ## Breaking Change: Action Entry Point and Response **Lit Actions must now be written as a `async function main()` that returns a value directly.** ```js theme={null} // New pattern async function main() { const result = await doSomething(); return result; } ``` The old patterns — IIFE (`(async () => { ... })()`) and `Lit.Actions.setResponse({ response })` — are no longer the recommended way to write actions. Use `return` from `main` to send a response to the caller. **Before:** ```js theme={null} (async () => { const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId })); const sig = await wallet.signMessage(message); Lit.Actions.setResponse({ response: { sig } }); })(); ``` **After:** ```js theme={null} async function main({ pkpId, message }) { const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId })); const sig = await wallet.signMessage(message); return { sig }; } ``` Early exits that previously called `setResponse` and then `return` now just `return` the value directly: ```js theme={null} async function main() { if (!condition) { return { error: "condition not met" }; } // ... } ``` *** ## Connecting to Chains In Datil and Naga, blockchain connectivity was managed by node operators. The network maintained a list of supported chains and their RPC endpoints, and developers used `Lit.Actions.getRpcUrl` to retrieve the URL for a given chain. If you needed support for a chain that wasn't already available, you had to contact Lit to have it added. **In Chipotle, this restriction no longer exists.** Connection information is now provided entirely by the client — you supply your own RPC URLs directly in your Lit Actions. This means **all EVM-compatible chains are available** without any configuration from Lit or node operators. **Before (Datil / Naga):** ```js theme={null} // Limited to chains pre-configured by node operators const rpcUrl = await Lit.Actions.getRpcUrl({ chain: "ethereum" }); const provider = new ethers.providers.JsonRpcProvider(rpcUrl); ``` **After (Chipotle):** ```js theme={null} // Any chain — just supply your own RPC URL const provider = new ethers.providers.JsonRpcProvider("https://your-rpc-url"); ``` If your RPC URL contains an API key, you can encrypt the key and have the Lit Action decrypt it at runtime — keeping the secret hidden while still verifiably targeting a specific chain. See [Securing RPC URLs](/lit-actions/patterns#securing-rpc-urls--hiding-api-keys-with-encryption) in the Patterns guide. You can connect to any chain by passing in the appropriate RPC endpoint — Ethereum, Polygon, Arbitrum, Base, or any other EVM-compatible network. There is no need to contact Lit to "add" chain support. *** ## Current Actions These actions are available in the current SDK (`Lit.Actions.*`). ### Encryption Encryption and decryption using a symmetric key derived from a PKP's secret key. #### `Lit.Actions.Encrypt` Encrypt a message using AES with a symmetric key derived from a PKP. **Parameters** * `params` **Object** * `params.pkpId` **string** — The ID of the PKP * `params.message` **string** — The message to encrypt Returns **Promise\** — The ciphertext *** #### `Lit.Actions.Decrypt` Decrypt data using AES with a symmetric key derived from a PKP. **Parameters** * `params` **Object** * `params.pkpId` **string** — The ID of the PKP * `params.ciphertext` **string** — The ciphertext to decrypt Returns **Promise\** — The decrypted plaintext *** ### PKP Keys Functions for retrieving private and public keys associated with PKPs and Lit Actions. #### `Lit.Actions.getPrivateKey` Get the private key for a PKP wallet. The key can then be used directly with ethers.js or any standard cryptographic library to sign data, derive addresses, or perform other key operations. **Parameters** * `params` **Object** * `params.pkpId` **string** — The ID of the PKP Returns **Promise\** — The private key secret *** #### `Lit.Actions.getLitActionPrivateKey` Get the private key for the currently executing Lit Action. The keypair is deterministically derived from the action's IPFS CID. Returns **Promise\** — The private key secret *** #### `Lit.Actions.getLitActionPublicKey` Get the public key for a Lit Action by IPFS ID. **Parameters** * `params` **Object** * `params.ipfsId` **string** — The IPFS ID of the Lit Action Returns **Promise\** — The public key *** #### `Lit.Actions.getLitActionWalletAddress` Get the wallet address for a Lit Action by IPFS ID. **Parameters** * `params` **Object** * `params.ipfsId` **string** — The IPFS ID of the Lit Action Returns **Promise\** — The wallet address *** ### Action Utilities #### `Lit.Actions.setResponse` Set the response returned to the client. Note that while this function remains, the suggested pattern is to simple use the `return` keyword at the end of a function. **Parameters** * `params` **Object** * `params.response` **any** — The response to send to the client. If this is not a string, it will be JSON-encoded before being sent. A value of undefined is encoded as null. *** ### Runtime Globals | Global | Description | | ------------ | ----------------------------------------------------------------------- | | `LitActions` | Alias for `Lit.Actions`, injected into the execution environment | | `ethers` | ethers.js v5 — wallets, providers, contracts, and cryptographic helpers | *** ## Deprecated Actions (Naga) The following actions were available in the Naga SDK. They are no longer part of the current runtime. Each section notes how to achieve equivalent behavior using current actions and on-chain settings. ### Signing Naga exposed high-level signing helpers that coordinated threshold signing across nodes. In the current SDK, retrieve a private key with `Lit.Actions.getPrivateKey` and use **ethers.js** directly to sign. Which PKPs a given action is permitted to access is governed by group membership and on-chain security settings configured via the Dashboard — no runtime permission check is required. #### ~~`Lit.Actions.ethPersonalSignMessageEcdsa`~~ *(deprecated)* Previously asked the Lit Node to sign a message using the `eth_personalSign` algorithm and automatically combine signature shares. **Replacement:** Use `Lit.Actions.getPrivateKey({ pkpId })` to retrieve the private key, then sign with `ethers.Wallet`: ```js theme={null} async function main({ pkpId, message }) { const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId })); const sig = await wallet.signMessage(message); return sig; } ``` *** #### ~~`Lit.Actions.signAsAction`~~ *(deprecated)* Previously signed data using the Lit Action's own cryptographic identity derived from its IPFS CID, enabling autonomous agent behavior and action-to-action authentication. **Replacement:** Use `Lit.Actions.getLitActionPrivateKey()` to retrieve the current action's private key, then sign with ethers.js. *** #### ~~`Lit.Actions.signAndCombineEcdsa`~~ *(deprecated)* Previously signed with ECDSA and automatically combined signature shares from all nodes into a complete signature. **Replacement:** Use `Lit.Actions.getPrivateKey({ pkpId })` and sign with ethers.js. *** #### ~~`Lit.Actions.signAndCombine`~~ *(deprecated)* Previously signed with any signing scheme and automatically combined signature shares. **Replacement:** Use `Lit.Actions.getPrivateKey({ pkpId })` and sign with the appropriate library for the target scheme. *** #### ~~`Lit.Actions.verifyActionSignature`~~ *(deprecated)* Previously verified that a signature was created by a specific Lit Action using `signAsAction`. **Replacement:** Retrieve the action's public key via `Lit.Actions.getLitActionPublicKey({ ipfsId })` and verify the signature using ethers.js or another standard cryptographic library. *** ### Checking Permissions These functions performed on-chain permission lookups at runtime. In the current model, permissions are enforced at the API gateway level based on on-chain group membership configured through the Dashboard. Actions do not need to query permissions themselves — if an action is executing, the required permissions have already been validated. #### ~~`Lit.Actions.isPermittedAction`~~ *(deprecated)* Previously checked whether a given IPFS ID was permitted to sign using a given PKP token ID. **Replacement:** Configure permitted actions for a PKP group using the Dashboard or directly via the `AccountConfig` smart contract. Permission is enforced on-chain before the action runs. *** #### ~~`Lit.Actions.isPermittedAddress`~~ *(deprecated)* Previously checked whether a given wallet address was permitted to sign using a given PKP token ID. **Replacement:** Manage wallet/PKP access via Dashboard group settings or directly on-chain. *** #### ~~`Lit.Actions.isPermittedAuthMethod`~~ *(deprecated)* Previously checked whether a given auth method was permitted to sign using a given PKP token ID. **Replacement:** Auth method restrictions are enforced through on-chain group configuration via the Dashboard. *** #### ~~`Lit.Actions.getPermittedActions`~~ *(deprecated)* Previously returned the full list of actions permitted to sign using a given PKP token ID. **Replacement:** Query group membership directly from the `AccountConfig` smart contract on-chain, or manage via the Dashboard. *** #### ~~`Lit.Actions.getPermittedAddresses`~~ *(deprecated)* Previously returned the full list of addresses permitted to sign using a given PKP token ID. **Replacement:** Query the `AccountConfig` contract on-chain or use the Dashboard. *** #### ~~`Lit.Actions.getPermittedAuthMethods`~~ *(deprecated)* Previously returned the full list of auth methods permitted to sign using a given PKP token ID. **Replacement:** Query the `AccountConfig` contract on-chain or use the Dashboard. *** #### ~~`Lit.Actions.getPermittedAuthMethodScopes`~~ *(deprecated)* Previously returned the permitted auth method scopes for a given PKP and auth method. **Replacement:** Query the `AccountConfig` contract on-chain or use the Dashboard. *** ### Key Management #### ~~`Lit.Actions.getActionPublicKey`~~ *(deprecated)* Previously retrieved the public key for a Lit Action's cryptographic identity given a signing scheme and IPFS CID. **Replacement:** Use `Lit.Actions.getLitActionPublicKey({ ipfsId })`. *** #### ~~`Lit.Actions.getLatestNonce`~~ *(deprecated)* Previously returned the latest nonce for a given address on a supported chain. **Replacement:** Use ethers.js directly: ```js theme={null} const provider = new ethers.providers.JsonRpcProvider(rpcUrl); const nonce = await provider.getTransactionCount(address); ``` *** #### ~~`Lit.Actions.claimKey`~~ *(deprecated)* Previously claimed a key through a key identifier and added the result to a claim registry. **Replacement:** PKP key management is now handled entirely through the Dashboard and on-chain `AccountConfig` contract. *** #### ~~`Lit.Actions.pubkeyToTokenId`~~ *(deprecated)* Previously converted a PKP public key to a token ID by hashing with keccak256. **Replacement:** Compute the hash directly with ethers.js: ```js theme={null} const tokenId = ethers.utils.keccak256(publicKey); ``` *** ### Action Utilities #### ~~`Lit.Actions.checkConditions`~~ *(deprecated)* Previously evaluated access control conditions using the Lit condition-checking engine at runtime. **Replacement:** Access control is enforced on-chain through group and PKP settings managed via the Dashboard or `AccountConfig` contract. Design your system so that permission is established before an action executes rather than checked inside the action. *** #### ~~`Lit.Actions.broadcastAndCollect`~~ *(deprecated)* Previously broadcast a message to all connected nodes and collected their responses. **Replacement:** No direct equivalent in the current runtime. Node coordination is now handled at the infrastructure level. *** #### ~~`Lit.Actions.runOnce`~~ *(deprecated)* Previously ran a function only once across all nodes using leader election. **Replacement:** No direct equivalent. Design actions to be idempotent across node execution. *** #### ~~`Lit.Actions.getRpcUrl`~~ *(deprecated)* Previously returned the RPC URL for a given blockchain. **Replacement:** Supply your own RPC URL and construct an ethers provider: ```js theme={null} const provider = new ethers.providers.JsonRpcProvider("https://your-rpc-url"); ``` *** #### ~~`Lit.Actions.encrypt`~~ *(deprecated)* Previously encrypted data using BLS encryption with access control conditions. **Replacement:** Use `Lit.Actions.Encrypt({ pkpId, message })`. Access control is enforced through on-chain group membership rather than runtime conditions. *** #### ~~`Lit.Actions.decryptAndCombine`~~ *(deprecated)* Previously decrypted and combined ciphertext subject to access control conditions, combining shares from all nodes. **Replacement:** Use `Lit.Actions.Decrypt({ pkpId, ciphertext })`. Access is governed by Dashboard group settings. *** #### ~~`Lit.Actions.decryptToSingleNode`~~ *(deprecated)* Previously decrypted data to a single node subject to access control conditions. **Replacement:** Use `Lit.Actions.Decrypt({ pkpId, ciphertext })`. *** #### ~~`Lit.Actions.aesDecrypt`~~ *(deprecated)* Previously decrypted data using AES with an explicitly provided symmetric key. **Replacement:** Use `Lit.Actions.Decrypt({ pkpId, ciphertext })`. The symmetric key is derived automatically from the PKP. *** ### Data Helpers #### ~~`Lit.Actions.uint8arrayToString`~~ *(deprecated)* Previously converted a Uint8Array to a string. **Replacement:** Use ethers.js utilities or standard JavaScript: ```js theme={null} const str = ethers.utils.toUtf8String(uint8Array); // or const str = new TextDecoder().decode(uint8Array); ``` *** #### ~~`Lit.Actions.uint8arrayFromString`~~ *(deprecated)* Previously converted a string to a Uint8Array. **Replacement:** Use ethers.js utilities or standard JavaScript: ```js theme={null} const bytes = ethers.utils.toUtf8Bytes(str); // or const bytes = new TextEncoder().encode(str); ``` *** ### Deprecated Runtime Globals | Global | Status | Notes | | ----------------------------- | ---------- | ---------------------------------------------------------------------------------------- | | `LitAuth` | Deprecated | Auth context is no longer injected; authentication is enforced on-chain before execution | | `jwt` | Deprecated | Use a standard JWT library bundled with your action if needed | | `Lit.Auth.actionIpfsIdStack` | Deprecated | No longer injected | | `Lit.Auth.authSigAddress` | Deprecated | No longer injected | | `Lit.Auth.authMethodContexts` | Deprecated | No longer injected | | `Lit.Auth.resources` | Deprecated | No longer injected | | `Lit.Auth.customAuthResource` | Deprecated | No longer injected | # Encryption & Decryption Source: https://developer.litprotocol.com/lit-actions/migration/encryption How encryption and decryption work in Chipotle compared to the official Lit SDK's access-control-conditions approach. ## The Lit V1 SDK Approach The Lit V1 / Naga SDK encrypt/decrypt flow using the `@lit-protocol/access-control-conditions` package was: 1. **Install the package** — `npm install @lit-protocol/access-control-conditions` 2. **Define access control conditions** — Build an `accs` object that describes *who* can decrypt (e.g. a specific wallet address, a token balance check, an NFT ownership check). These are evaluated at decrypt time across the Lit node network. 3. **Encrypt (no auth required)** — Call `litClient.encrypt({ dataToEncrypt, unifiedAccessControlConditions, chain })`. Anyone can encrypt; the conditions only gate decryption. 4. **Authenticate the decryptor** — The decryptor must produce an `authContext` by signing a SIWE message via `authManager.createEoaAuthContext(...)`. This proves wallet ownership to the nodes. 5. **Decrypt** — Call `litClient.decrypt({ data, unifiedAccessControlConditions, authContext, chain })`. The nodes verify the auth context against the conditions, combine decryption shares, and return the plaintext. **Key characteristics of this approach:** * Access control conditions are immutable — they are baked into the ciphertext at encryption time and cannot be changed without re-encrypting * The decryptor must authenticate with a wallet signature on every decrypt call * Conditions can reference on-chain state (balances, NFTs, DAO membership) evaluated at the moment of decryption * Encryption happens client-side using BLS; decryption shares are combined by the node network * Requires the `@lit-protocol/access-control-conditions` SDK package and a running Lit node connection *** ## The Chipotle Approach In Chipotle, encryption and decryption happen **inside a Lit Action** running in a TEE using `Lit.Actions.Encrypt` and `Lit.Actions.Decrypt`. The symmetric key is derived from a PKP. What makes this model flexible is that the Lit Action itself is plain JavaScript — so you can implement any gating logic you need (API calls, on-chain checks, parameter validation) before deciding whether to encrypt or decrypt. **Access control in Chipotle has two layers:** 1. **Structural (on-chain)** — The Dashboard's group and scope configuration determines which API keys can call which actions against which PKPs. This is enforced before the action runs. These settings can be **locked** (by revoking management scopes from all API keys, requiring a SAFE multisig to change) or left **updatable** (by retaining `group:manageActions` scope on a key). 2. **In-action (programmatic)** — The Lit Action itself can implement arbitrary gating conditions before calling `Encrypt` or `Decrypt`: check an API key passed as a parameter, fetch an external API, verify a signature, read a smart contract state. This gives you full flexibility without touching on-chain config. Encryption can be tied to a **user account** (a PKP belonging to a specific user), a **group** (a PKP shared by a set of users), or any other logical boundary you model with PKPs. Decryption can similarly be gated on authentication (check a token or signature in `jsParams`) or any external condition your action can verify. Because the action is just an HTTP call, no SDK is required — you can call the node from any environment that can make HTTP requests: a browser, a server, a mobile app, a cron job, a Rust binary, or a shell script. ### Encrypt (with optional gating) ```js theme={null} // jsParams: { pkpId, message, optional-userToken } async function main({ pkpId, message }) { // Optional gate: verify caller before encrypting const authRes = await someGatedCheck(); if (!authRes.ok) { return { error: 'Unauthorized' }; } const ciphertext = await Lit.Actions.Encrypt({ pkpId, message }); return { ciphertext }; } ``` ### Decrypt (with optional gating) ```js theme={null} // jsParams: { pkpId, ciphertext, optional-userToken } async function main({ pkpId, ciphertext }) { // Optional gate: check condition before decrypting const authRes = await someGatedCheck(); if (!authRes.ok) { return { error: 'Unauthorized' }; } const plaintext = await Lit.Actions.Decrypt({ pkpId, ciphertext }); return { plaintext }; } ``` The gate can be anything — an auth token check, a smart contract read, a price feed, a weather API, or simply a value in `jsParams`. The encrypt/decrypt calls only happen if your logic allows it. **Key characteristics of the Chipotle approach:** * Access conditions can be **locked** (revoke management scopes via Dashboard) or **changed later** (update group/scope settings without re-encrypting) * Encryption can be tied to a user account PKP, a shared group PKP, or any PKP-level boundary you define * Decryption can be gated on authentication (a token, a signature, a session) or any programmatic condition inside the action * No SDK required — works from any HTTP client in any language or environment * The symmetric key is derived deterministically from the PKP — the same PKP always produces the same encryption key *** ## Side-by-Side Comparison | | Official SDK (Naga) | Chipotle | | ---------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | **Where encryption runs** | Client-side (caller's machine) | Inside a Lit Action (TEE) | | **Access control** | Immutable conditions baked into ciphertext at encrypt time | Two layers: on-chain structural config + in-action programmatic gating | | **Can access rules change later?** | No — re-encrypt required | Yes — update group/scope settings in Dashboard or on-chain (no re-encrypt needed for structural changes) | | **Auth required to decrypt** | Yes — wallet signature (SIWE) on every call | Optional — gate however you like inside the action | | **Encryption scope** | Tied to conditions (wallet address, token balance, etc.) | Tied to a PKP — user account, group, or any logical boundary | | **Key material** | BLS threshold key shares combined across nodes | Symmetric key derived from PKP secret inside TEE | | **SDK required** | Yes — `@lit-protocol/access-control-conditions` | No — plain HTTP from any environment | | **Languages / environments** | JavaScript/TypeScript (Node.js or browser) | Any — browser, server, mobile, shell, Rust, Python, etc. | | **Encrypted data portability** | Ciphertext is portable; decryptable by anyone meeting conditions | Ciphertext is portable; decryptable by any action with access to the same PKP | *** ## When to Use Each Approach **Use the official SDK conditions approach** when: * You need wallet-authenticated, dynamic access control checked against on-chain state at decrypt time * Decryptors are external wallets interacting directly with the Lit network * Conditions must be permanently immutable (baked into the ciphertext) **Use the Chipotle approach** when: * Encryption and decryption happen inside a Lit Action (server-side secrets, encrypted storage, API key vaulting) * You want to gate access with arbitrary logic — external APIs, signatures, parameters — without on-chain condition overhead * You need to call from environments without JavaScript SDK support * You want the flexibility to update access rules later without re-encrypting all existing data The Chipotle `Encrypt`/`Decrypt` functions are not a drop-in replacement for the full access-control-conditions system when you need dynamic, wallet-authenticated decryption tied to immutable on-chain conditions. They are a more flexible primitive suited for server-side secrets management within the Lit Action execution environment. # Patterns Source: https://developer.litprotocol.com/lit-actions/patterns Common design patterns for Lit Actions: writing gating logic in plain JavaScript, using action-identity signing to produce immutable proofs, and structuring encrypt/decrypt flows around PKP wallets. ## Gating Logic — AKA Access Control Conditions The older Datil / Naga Lit SDK offered a fluent builder for declaring access control conditions (ACCs): structured objects that describe *who* may decrypt ciphertext or call an action. A typical condition using the SDK looks like this: ```ts theme={null} import { createAccBuilder } from '@lit-protocol/access-control-conditions'; const conditions = createAccBuilder() .on('ethereum') .requireEthBalance('1000000000000000000') // 1 ETH in wei .and() .on('ethereum') .requireNftOwnership('0xContractAddress') .build(); ``` This produces a serialized conditions array that is passed to encrypt and decrypt calls, evaluated by the Lit node network at runtime. **In Chipotle, you skip the builder entirely.** Your Lit Action is JavaScript — so you write the gate as code. The result is simpler, easier to read, and far more flexible: you can call external APIs, read any chain, verify signatures, or apply any conditional logic that JavaScript supports. The equivalent of the ETH-balance check above, written directly in a Lit Action: ```javascript theme={null} // js_params: { pkpId, minBalanceWei, message } // pkpId doubles as the wallet address to check — it is an Ethereum address. async function main({ pkpId, minBalanceWei, message }) { const provider = new ethers.providers.JsonRpcProvider("https://mainnet.base.org"); const balance = await provider.getBalance(pkpId); if (balance.lt(ethers.BigNumber.from(minBalanceWei))) { return { error: `Balance ${ethers.utils.formatEther(balance)} ETH is below the required minimum`, }; } // Gate passed — sign the message with the PKP. const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId })); const signature = await wallet.signMessage(message); return { message, signature, balanceEth: ethers.utils.formatEther(balance) }; } ``` The same pattern extends to any condition you can express in JavaScript: ```javascript theme={null} // js_params: { pkpId, contractAddress, requiredAmount, message } // Gate on ERC-20 token balance instead of ETH. async function main({ pkpId, contractAddress, requiredAmount, message }) { const provider = new ethers.providers.JsonRpcProvider("https://mainnet.base.org"); const erc20 = new ethers.Contract( contractAddress, ["function balanceOf(address) view returns (uint256)"], provider ); const balance = await erc20.balanceOf(pkpId); if (balance.lt(ethers.BigNumber.from(requiredAmount))) { return { error: "Insufficient token balance" }; } const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId })); const signature = await wallet.signMessage(message); return { message, signature }; } ``` Because gating is just code, you can combine conditions arbitrarily — NFT ownership AND minimum ETH balance AND a timestamp check — without learning a builder API or worrying about condition serialization. *** ## Action-Identity Signing — Immutable Proofs Every Lit Action has a cryptographic identity derived from its IPFS CID. `Lit.Actions.getLitActionPrivateKey()` retrieves a private key that is **deterministically derived from the content hash of the action code**. There is no way to produce that key outside of that exact code running inside the Lit network. This means any signature produced with this key carries a guarantee: **the data was produced by that specific, immutable action**. If the action code changes by a single byte, it gets a new IPFS CID, a new key, and a new identity. There is no way to forge the signature without controlling both the Lit network and the exact source code. This is useful any time you want to produce a **verifiable proof** — a signed output that a smart contract, API, or third party can verify came from a specific computation, not an arbitrary off-chain script. ### Example: Signing a Price Feed ```javascript theme={null} // js_params: {} — no caller-supplied parameters needed async function main() { const res = await fetch( "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd" ); const data = await res.json(); const price = data?.ethereum?.usd; if (typeof price !== "number") { return { error: "Price fetch failed" }; } // Sign with the action's own key — not a PKP. // The private key is derived from this action's IPFS CID. const actionWallet = new ethers.Wallet( await Lit.Actions.getLitActionPrivateKey() ); const payload = `ETH/USD: ${price} at ${Date.now()}`; const signature = await actionWallet.signMessage(payload); return { price, payload, signature, signerAddress: actionWallet.address, }; } ``` To verify the output, any caller can: 1. Retrieve the action's public key or wallet address via `Lit.Actions.getLitActionPublicKey({ ipfsId })` (callable from inside another action) or by fetching it once and caching it. 2. Call `ethers.utils.verifyMessage(payload, signature)` and compare the recovered address to the known action address. If the addresses match, the caller has a cryptographic proof that this specific, immutable action code produced this specific output — without trusting any intermediary. Use `getLitActionPrivateKey` when the proof must be tied to the action code itself. Use `getPrivateKey({ pkpId })` when the proof must be tied to a specific PKP wallet (e.g. an account or a user's identity). Both patterns produce verifiable signatures; they differ in what identity the signature is bound to. ### What if Someone Else Runs My Action? A common concern with Action-Identity Signing is: **what happens if someone copies my Lit Action and runs it themselves?** **In many cases, it doesn't matter.** Because the action's identity is tied to its IPFS CID, anyone running the same action is executing the exact same immutable code. If your action only does a single thing — like signing a price feed — then someone else running it is simply paying for the execution on your behalf. The output carries the same cryptographic guarantee regardless of who triggered it. This applies to pure, side-effect-free actions. If your action writes to an external database, calls a third-party API, or triggers transactions, unauthorized execution could cause duplicate writes or exhaust rate limits. **If you need to restrict who can execute the action**, there are three layers of control: 1. **API key scoping (primary).** Configure which API keys have `execute` permission on which groups through the [Dashboard](/management/dashboard). This is enforced at the API gateway before the action runs, so unauthorized callers never reach your code. 2. **PKP address check.** If the group is configured so that only a specific PKP can be used with the action, you can check the PKP address as a parameter inside the action. Although `js_params` are caller-supplied, the ownership model makes this reliable: the caller can only use PKPs that belong to their account and are permitted within the group. An attacker cannot pass an arbitrary PKP address because the gateway already verified that the PKP is owned by the caller's account. ```javascript theme={null} async function main({ pkpAddress }) { const ALLOWED_PKP = "0xAbc123..."; // your PKP's wallet address if (pkpAddress.toLowerCase() !== ALLOWED_PKP.toLowerCase()) { throw new Error("Unauthorized: PKP not allowed to run this action"); } // ... rest of your action logic } ``` 3. **Signature verification (defense-in-depth).** For additional assurance beyond the ownership model, require the caller to sign a challenge and verify the signature cryptographically inside the action. The `message` should include a nonce or timestamp so that a previously captured signature cannot be replayed: ```javascript theme={null} async function main({ signature, message }) { const ALLOWED_ADDRESS = "0xAbc123..."; // the authorized caller's wallet address // message should contain a nonce or timestamp for replay protection const recovered = ethers.utils.verifyMessage(message, signature); if (recovered.toLowerCase() !== ALLOWED_ADDRESS.toLowerCase()) { throw new Error("Unauthorized: caller signature does not match allowed address"); } // ... rest of your action logic } ``` Note that someone could always copy your publicly available Lit Action source code, strip out the gating logic, and deploy it as a new action. This is completely safe from the original creator's perspective — the modified action produces a different IPFS CID, which means a different key pair and a different identity. It cannot produce signatures that appear to come from your original action. *** ## Encrypt / Decrypt — PKP Wallets as Data Vaults ### One PKP per logical data boundary The best practice for encrypting a set of related data is to **create a dedicated PKP wallet for that data boundary**. The PKP's derived symmetric key is then used to encrypt everything in that boundary — user records, API keys, configuration, documents, whatever belongs together. ``` Account └── Group ├── PKP: "user-alice-data-vault" ← one vault per user ├── PKP: "user-bob-data-vault" └── PKP: "app-secrets" ← one vault per concern ``` Encrypt each item with its vault's PKP: ```javascript theme={null} // js_params: { pkpId, plaintext } // Run once to seal a piece of data into the vault. async function main({ pkpId, plaintext }) { const ciphertext = await Lit.Actions.Encrypt({ pkpId, message: plaintext }); return { ciphertext }; } ``` Store the returned `ciphertext` anywhere — IPFS, a database, a smart contract — without risk. Without the PKP's derived key (which never leaves the TEE), the ciphertext is opaque. ### Giving dApp users access through gating conditions To let a dApp user read encrypted data, you give them access to a **decrypt action** that enforces your gating conditions before calling `Lit.Actions.Decrypt`. The user calls the action with a reference to the vault PKP; if their condition is met, they receive the plaintext. If not, they receive an error. **The symmetric key is never shared.** It is derived inside the TEE, used to decrypt, and discarded. The user only ever sees the plaintext result — and only if the action's gating logic allows it. ```javascript theme={null} // js_params: { pkpId, ciphertext, userToken } // userToken is a signed JWT or session token the caller supplies to prove identity. async function main({ pkpId, ciphertext, userToken }) { // Verify the caller's token against your auth service. const authRes = await fetch("https://auth.your-app.com/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: userToken }), }); const auth = await authRes.json(); if (!auth.valid) { return { error: "Unauthorized" }; } // Gate passed — decrypt and return the plaintext. const plaintext = await Lit.Actions.Decrypt({ pkpId, ciphertext }); return { plaintext }; } ``` You can substitute any condition for the token check — an on-chain balance, NFT ownership, a subscription status API, a time window, or a combination: ```javascript theme={null} // js_params: { pkpId, ciphertext, holderAddress, nftContractAddress } // Only NFT holders may decrypt. async function main({ pkpId, ciphertext, holderAddress, nftContractAddress }) { const provider = new ethers.providers.JsonRpcProvider("https://mainnet.base.org"); const nft = new ethers.Contract( nftContractAddress, ["function balanceOf(address) view returns (uint256)"], provider ); const balance = await nft.balanceOf(holderAddress); if (balance.eq(0)) { return { error: "NFT not held — access denied" }; } const plaintext = await Lit.Actions.Decrypt({ pkpId, ciphertext }); return { plaintext }; } ``` *** ## Securing RPC URLs — Hiding API Keys with Encryption Many RPC providers require an API key appended to the endpoint URL (e.g. `https://mainnet.infura.io/v3/YOUR_API_KEY`). Passing the full URL as a `js_params` value would expose the key to anyone who can inspect the action call. Instead, you can **encrypt the secret portion** of the URL and let the Lit Action decrypt and reassemble it at runtime inside the TEE. This pattern provides two guarantees: 1. **The API key is never visible** to the caller or in the transaction parameters — it only exists in plaintext inside the TEE during execution. 2. **The action is verifiably calling a specific chain** — the base URL is passed in the clear (or hardcoded), so observers can confirm which network the action targets, while the confidential key portion stays hidden. ### Setup: Encrypt the API key Use a dedicated "secrets" PKP to encrypt the API key once. Store the resulting ciphertext alongside the action or in your configuration. ```javascript theme={null} // Encrypt the API key portion once and store the ciphertext. // js_params: { pkpId, apiKey } async function main({ pkpId, apiKey }) { const ciphertext = await Lit.Actions.Encrypt({ pkpId, message: apiKey }); return { ciphertext }; } ``` ### Usage: Decrypt and connect at runtime The production action receives the encrypted API key, decrypts it inside the TEE, and assembles the full RPC URL. ```javascript theme={null} // js_params: { pkpId, encryptedApiKey, targetAddress } async function main({ pkpId, encryptedApiKey, targetAddress }) { // Decrypt the API key inside the TEE — it never leaves the node. const apiKey = await Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedApiKey }); // Assemble the full RPC URL. const provider = new ethers.providers.JsonRpcProvider( `https://sepolia.infura.io/v3/${apiKey}` ); // Use the provider as normal. const balance = await provider.getBalance(targetAddress); return { balance: ethers.utils.formatEther(balance) }; } ``` You should hard-code the base URL directly in the action code rather than passing it as a parameter. Because the action is pinned to IPFS, hardcoding makes the target chain immutable — verifiable by anyone who inspects the action's CID. *** ### Why this is safe to expose Sharing a PKP ID (the wallet address) with users is safe because: * The **private key** is only accessible inside a Lit Action running in a TEE — it is never returned to callers and never leaves the node. * The **symmetric key** used for encryption is derived from the private key inside the TEE. It is also never returned. * The **ciphertext** is meaningless without the derived key. * The only way to get plaintext is to run an action that holds the right PKP and chooses to call `Decrypt` — so your gating logic is the sole enforcement point. Users can hold the `pkpId` and the `ciphertext` indefinitely. They gain access to the plaintext only if and when the gating logic inside the action is satisfied. # API Mode vs ChainSecured Mode Source: https://developer.litprotocol.com/management/account_modes How the two account ownership models differ, when to choose each, and how to move from API mode to ChainSecured. Chipotle accounts come in two flavors. Both speak to the same on-chain contracts and run the same Lit Actions; they only differ in **who owns the account and how administrative writes are signed**. * **API mode** (managed) — `POST /core/v1/new_account` generates a fresh random secret server-side and returns it once as a base64 API key, along with the wallet address derived from that secret. You hold the key; that key *is* the account credential. Admin writes are sent as HTTP calls and the server submits the on-chain transaction on your behalf. * **ChainSecured mode** (unmanaged) — A wallet you control (an EOA, a Safe, or any contract account on Base) is the account owner directly on-chain. Admin writes are wallet-signed transactions you submit yourself. There is no account-level API key. Both modes share the same `/core/v1` API for *executing* Lit Actions. The difference is only in *administrative* operations — creating groups, adding actions, registering PKPs, minting usage keys, etc. ## Side-by-side | Dimension | API mode | ChainSecured mode | | ------------------------ | --------------------------------------------------------- | --------------------------------------------------- | | Account owner | Wallet derived from a server-generated random secret | Your wallet (EOA / Safe / contract) on Base | | Account-level credential | Base64 API key (`X-Api-Key` header) | None — wallet signature is the credential | | Admin write path | HTTP `POST /core/v1/...` → server submits the tx | Direct contract call from your wallet | | Gas for admin writes | Server pays (covered by the per-call credit charge) | You pay gas from the connected wallet | | Recovery | Retain/back up the API key; if lost, create a new account | Whatever your wallet supports (seed, Safe signers) | | On-chain `managed` flag | `true` | `false` | | Onboarding speed | Fastest — paste an email, get a key | Requires a funded wallet on Base | | Trust model | You trust Lit's server to relay your intent | Trust-minimized — every admin write is on-chain | | Auditability | Server logs + on-chain events | On-chain events only; every change is wallet-signed | | Dashboard surface | Same management UI | Same management UI; writes prompt the wallet | | Lit Action execution | Usage API key in `X-Api-Key` | Usage API key in `X-Api-Key` (minted from contract) | | Billing | Stripe credits on the account | Stripe credits on the account (same flow) | ChainSecured mode is referred to as *self-sovereign* in some internal material. They are the same thing — wallet ownership of the account on Base, with no required server round-trip for admin writes. ## When to pick which ### Pick API mode if: * You want to ship today and don't want to manage gas, an RPC, or a wallet popup in your admin tooling. * Your client is a server, a cron job, or a CI pipeline that needs a single shared credential. * You're prototyping or iterating quickly — onboarding is fast, usage API keys are rotatable (mint and revoke at will), and the dashboard reflects every change immediately. * You don't have a strong requirement that *every* configuration change be visible on-chain. API mode is the **default** and is shown as `Recommended` in the dashboard's login screen. ### Pick ChainSecured mode if: * You want self-custody of the account: no third party (including Lit) can unilaterally create groups, add actions, or mint usage keys on your behalf. * A multisig (Safe) or DAO governs configuration changes — every action upgrade, every PKP added to a group, becomes a Safe proposal that signers can review. * You want a fully on-chain audit trail of every admin operation, signed by your governance wallet. * You're integrating with a wallet-native dApp where the user's connected wallet is already the natural source of authority. ChainSecured accounts have `managed = false` on-chain and reject any admin write that does not originate from the registered admin wallet. ## How the wiring differs ### API mode (`mode: 'api'`, default) Calls go over HTTP with your account API key in the header. The Core SDK default constructor is API mode: ```javascript theme={null} import { createClient } from './core_sdk.js'; const client = createClient('https://api.chipotle.litprotocol.com'); const res = await client.newAccount({ accountName: 'My App', accountDescription: 'Optional', email: 'optional@example.com', }); console.log('API key:', res.api_key); // store this console.log('Wallet:', res.wallet_address); ``` Every subsequent management call (`addGroup`, `addAction`, `addUsageApiKey`, ...) takes that API key in `X-Api-Key`. See [API direct usage](/management/api_direct) for the full workflow. ### ChainSecured mode (`mode: 'sovereign'`) The Core SDK is constructed with `mode: 'sovereign'`, an RPC URL, and the on-chain `AccountConfig` contract address. Reads call the contract directly; writes are wallet-signed and submitted via the connected signer. Once a signer with a provider is attached (any ethers v6 signer that carries a provider — for example, a `JsonRpcSigner` returned by `BrowserProvider.getSigner()`), the SDK routes reads through that provider instead of `rpcUrl`. The `rpcUrl` constructor option is the fallback used for reads that happen before a signer is attached. ```javascript theme={null} import { LitNodeSimpleApiClient } from './core_sdk.js'; import { ethers } from 'ethers'; const provider = new ethers.BrowserProvider(window.ethereum); const signer = await provider.getSigner(); const client = new LitNodeSimpleApiClient({ baseUrl: 'https://api.chipotle.litprotocol.com', mode: 'sovereign', rpcUrl: 'https://mainnet.base.org', contractAddress: '0xYourAccountConfigDiamond', signer, }); // Creates an unmanaged account whose admin is the connected wallet. const res = await client.newChainSecuredAccount({ accountName: 'My App', accountDescription: 'Optional', }); console.log('Admin wallet:', res.wallet_address); console.log('Tx hash:', res.transaction_hash); ``` Logging back in is a wallet connect — no key to paste: ```javascript theme={null} client.connectSigner(signer); const apiKeyHash = ethers.solidityPackedKeccak256( ['address'], [await signer.getAddress()], ); const exists = await client.accountExistsByHash(apiKeyHash); ``` PKP minting in ChainSecured mode uses an EIP-712 typed-data signature (`primaryType: "CreateWallet"`) that the server verifies before deriving key material via the TEE; the client then registers the derivation path on-chain in a second wallet-signed tx. Once the signer is connected and the address-derived `adminHashOverride` is set on the client (the dashboard does this automatically at login), `createWallet({ name, description })` does both steps for you — no account-level `apiKey` is required in ChainSecured mode. Call `GET /get_node_chain_config` (no auth required) for the live `contract_address` and `chain_id`. The RPC URL is not returned by the API — supply your own Base RPC endpoint as the `rpcUrl` fallback (any public Base RPC works, e.g. `https://mainnet.base.org` or `https://base-rpc.publicnode.com`). Once your signer is attached the SDK prefers the wallet's RPC, so this fallback only matters for the brief window before `connectSigner(signer)` runs. ## Using the dashboard The Chipotle Dashboard offers both modes side-by-side on the login screen: * **Sign in** tab → "API mode" card (paste your API key) or "ChainSecured mode" card (Connect wallet). * **Create account** tab → "API mode" card (email + name) or "ChainSecured mode" card (Connect wallet & create). Once authenticated the dashboard renders the same surface in both modes. ChainSecured admin operations open a transaction preview before prompting the wallet to sign; API-mode operations submit immediately. Billing, balance display, and Add Funds are identical in both modes — Stripe credits fund Lit Action execution either way. API-mode users see one extra item in the account dropdown: **Convert to ChainSecured**, which kicks off the conversion flow described below. ## Converting an API account to ChainSecured Conversion flips a managed account to unmanaged in a single on-chain transaction. The account's on-chain `apiKeyHash` is preserved, so groups, PKPs, action metadata, usage API keys, and the Stripe credit balance all stay attached to the same account record. Only the admin wallet address and the `managed` flag change. The contract function is `WritesFacet.convertToChainSecuredAccount(uint256 apiKeyHash, address newAdminWalletAddress)`, which is `apiPayerOrOwner`-gated. End users don't call it directly — they call `POST /core/v1/convert_to_chain_secured_account` with their existing API key plus a wallet-signed proof of ownership of the new admin address; the server's api\_payer signs the on-chain conversion on their behalf. ### What's preserved * The on-chain `apiKeyHash` (so all child resources stay attached). * **Groups** (permitted PKP IDs and CID hashes). * **PKPs** (derivation paths, names, descriptions). * **Action metadata** (registered IPFS CID names and descriptions). * **Usage API keys** — they continue to authorize Lit Action execution exactly as before. * **Stripe credit balance** — the wallet\_cache entry is invalidated on conversion so billing routes to the new admin wallet's customer record immediately. ### What changes * `account.adminWalletAddress` becomes the new wallet you signed with. * `account.managed` flips from `true` to `false`. * Admin write authority moves entirely to the connected wallet. The original master API key can no longer authorize writes on this account (the contract rejects api\_payer relays for unmanaged accounts). * Read endpoints that key off `apiKeyHash` continue to resolve to the same account. ### Step-by-step (dashboard) 1. **Sign in to the dashboard in API mode** with your existing master API key. 2. Open the account dropdown and click **Convert to ChainSecured**. Confirm the irreversible-action prompt. 3. **Connect the wallet** that will become the new admin. **EOA only — the server verifies the EIP-712 signature via plain ECDSA `recover()` and does not yet validate ERC-1271 contract-account signatures**, so Safes and other contract wallets are not supported in this flow today. The dashboard prompts a chain switch if your wallet isn't on the chain reported by `GET /get_node_chain_config`. 4. **Sign the EIP-712 ownership-transfer typed data.** The dashboard composes a typed-data envelope with `primaryType: "ConvertAccount"` — wallet UIs surface it as a labelled struct (`address`, `issuedAt`) under the `Lit ChainSecured` domain rather than a free-form message. The full canonical envelope is: ```json theme={null} { "types": { "EIP712Domain": [ { "name": "name", "type": "string" }, { "name": "version", "type": "string" }, { "name": "chainId", "type": "uint256" } ], "ConvertAccount": [ { "name": "address", "type": "address" }, { "name": "issuedAt", "type": "uint256" } ] }, "primaryType": "ConvertAccount", "domain": { "name": "Lit ChainSecured", "version": "1", "chainId": "" }, "message": { "address": "", "issuedAt": "" } } ``` `types` is part of the EIP-712 type hash and the server schema validator rejects payloads where it differs by even one field — field declaration order matters. Your wallet produces an EIP-712 signature (`eth_signTypedData_v4`). 5. The dashboard `POST`s `/core/v1/convert_to_chain_secured_account` with `{ new_admin_wallet_address, typed_data, signature }` and your existing API key in the header. The server verifies the typed-data digest recovers to the new admin address, the `chainId` matches the node, the `primaryType` matches `ConvertAccount` (preventing cross-flow replay against the secret-emitting endpoints), and the `issuedAt` timestamp is within ±300 seconds, then has the api\_payer submit the on-chain conversion. 6. On success, the dashboard switches mode to `sovereign`, clears the stored API key, persists the wallet + preserved `apiKeyHash` to the ChainSecured session, and reloads. ### Step-by-step (SDK) ```javascript theme={null} import { createClient } from './core_sdk.js'; import { ethers } from 'ethers'; // Sign in with API mode (default) and your existing master API key. const client = createClient('https://api.chipotle.litprotocol.com'); // Connect the wallet that will become the new on-chain admin. const provider = new ethers.BrowserProvider(window.ethereum); const signer = await provider.getSigner(); // Look up the chain id the server expects so the EIP-712 domain matches. const cfg = await client.getNodeChainConfig(); const res = await client.convertToChainSecuredAccount({ apiKey: existingApiKey, signer, chainId: Number(cfg.chain_id), }); console.log('New admin wallet:', res.wallet_address); console.log('Preserved apiKeyHash:', res.api_key_hash); ``` `convertToChainSecuredAccount` is API-mode only and throws if called from a sovereign-mode client. The returned `api_key_hash` is the same hash the account had before conversion — keep it around if you need to seed a sovereign-mode session for this account (the dashboard does this for you automatically). ### Things to verify after conversion * `accountExistsByHash(api_key_hash)` returns `true` from the wallet context, where `api_key_hash` is the value returned by `convertToChainSecuredAccount` (the preserved on-chain hash). * An admin write attempted with the original API key (e.g. `addUsageApiKey`) fails — the contract rejects it now that the account is unmanaged. * An admin write signed by the new wallet succeeds. * All groups, PKPs, action CIDs, and existing usage API keys are visible in the dashboard under the new wallet session, and a Lit Action run with one of those usage keys still succeeds. * The Stripe credit balance is unchanged. ### Reverse direction There is no path back from ChainSecured to API mode. The contract reverts `convertToChainSecuredAccount` if the account is already unmanaged. If you need a managed account again, create a new one with `newAccount` and re-add resources manually. ## Further reading * [Auth Model](/architecture/authModel) — owner / API key / scope model in detail. * [Architecture diagram](/architecture/diagram) — how the TEE, contracts, and dashboard fit together. * [Using the API directly](/management/api_direct) — endpoint-by-endpoint reference (API mode). * [Pricing](/management/pricing) — credit model, identical in both modes. # API Source: https://developer.litprotocol.com/management/api_direct Using the API directly to configure Chipotle and execute actions. ## Using the API directly The same workflows can be done via the REST API. The API itself is under `/core/v1/`. All endpoints *that require authentication* expect the API key in a header: * `X-Api-Key: your-api-key` * or `Authorization: Bearer your-api-key` Examples below assume `KEY=your_account_or_usage_api_key`. cURL snippets use `BASE=https://api.chipotle.litprotocol.com` (hosted production API) and JavaScript snippets use `BASE=https://api.dev.litprotocol.com` (hosted dev API); change the `BASE` value if you want to target the other environment. The JavaScript examples use the Core SDK (`LitNodeSimpleApiClient`) from `core_sdk.js`. **API workflow:** 1. [New account or verify account (login)](#1-new-account-or-verify-account-login) 2. [Add funds](/management/pricing#paying-with-a-credit-card-via-stripe) (or [via the billing API](#billing)) 3. [Add usage API key](#3-add-usage-api-key) 4. [Create wallet (PKP)](#4-create-a-wallet-pkp) 5. [Add group and register IPFS action](#5-add-group-and-register-ipfs-action) 6. [Add PKP to group (optional)](#6-add-pkp-to-group-optional) 7. [Run lit-action](#7-run-lit-action) ### 1. New account or verify account (login) Create a new account (returns API key and wallet address). Or verify an existing key with the `account_exists` function. ```javascript theme={null} import { createClient } from './core_sdk.js'; const client = createClient('https://api.chipotle.litprotocol.com'); // New account const res = await client.newAccount({ accountName: 'My App', accountDescription: 'Optional description', email: 'optional@example.com' // optional — forwarded to Stripe }); console.log('API key:', res.api_key); console.log('Wallet:', res.wallet_address); // Store res.api_key securely. // Or verify existing key (login) const exists = await client.accountExists(res.api_key); console.log('Account exists:', exists); ``` ```bash theme={null} # New account curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/new_account" \ -H "Content-Type: application/json" \ -d '{"account_name":"My App","account_description":"Optional","email":"optional@example.com"}' # Verify account (replace KEY with your API key) curl -s "https://api.chipotle.litprotocol.com/core/v1/account_exists" \ -H "X-Api-Key: KEY" ``` ### 3. Add usage API key Create a usage API key with fine-grained permissions. The response includes the new key only once — store it immediately. ```javascript theme={null} const res = await client.addUsageApiKey({ apiKey: accountApiKey, name: 'My Usage Key', description: 'Used by my dApp', canCreateGroups: false, canDeleteGroups: false, canCreatePkps: false, manageIpfsIdsInGroups: [], // group IDs; 0 = wildcard (all groups) addPkpToGroups: [], removePkpFromGroups: [], executeInGroups: [groupId] // grant execute permission for specific groups }); console.log('New usage API key (store it now):', res.usage_api_key); ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/add_usage_api_key" \ -H "Content-Type: application/json" \ -H "X-Api-Key: KEY" \ -d '{ "name": "My Usage Key", "description": "Used by my dApp", "can_create_groups": false, "can_delete_groups": false, "can_create_pkps": false, "manage_ipfs_ids_in_groups": [], "add_pkp_to_groups": [], "remove_pkp_from_groups": [], "execute_in_groups": [1] }' ``` **Permission fields:** | Field | Type | Description | | --------------------------- | ------- | --------------------------------------------------------------------------------------------- | | `can_create_groups` | bool | Allow this key to create new groups | | `can_delete_groups` | bool | Allow this key to delete groups | | `can_create_pkps` | bool | Allow this key to create PKPs | | `manage_ipfs_ids_in_groups` | `u64[]` | Group IDs where this key can add/remove IPFS actions. Use `[0]` as a wildcard for all groups. | | `add_pkp_to_groups` | `u64[]` | Group IDs where this key can add PKPs. Use `[0]` for all groups. | | `remove_pkp_from_groups` | `u64[]` | Group IDs where this key can remove PKPs. Use `[0]` for all groups. | | `execute_in_groups` | `u64[]` | Group IDs where this key can execute lit-actions. Use `[0]` for all groups. | ### 4. Create a wallet (PKP) Request a new wallet (PKP) for the account. The server returns the wallet address and registers it. ```javascript theme={null} const res = await client.createWallet(accountApiKey); console.log('Wallet address:', res.wallet_address); ``` ```bash theme={null} curl -s "https://api.chipotle.litprotocol.com/core/v1/create_wallet" \ -H "X-Api-Key: KEY" ``` ### 5. Add group and register IPFS action Create a group, then add an action (IPFS CID) to scope which keys can run it. ```javascript theme={null} // Create group await client.addGroup({ apiKey: accountApiKey, groupName: 'My Group', groupDescription: 'Optional', pkpIdsPermitted: [], // PKP IDs pre-permitted in this group cidHashesPermitted: [] // CID hashes pre-permitted in this group }); // List groups to get the new group ID const groups = await client.listGroups({ apiKey: accountApiKey, pageNumber: '0', pageSize: '10' }); const groupId = groups[groups.length - 1].id; // Add an IPFS action (CID) to the group await client.addActionToGroup({ apiKey: accountApiKey, groupId, // u64 actionIpfsCid: 'QmYourIpfsCidHere' // CID is hashed on the server }); ``` ```bash theme={null} # Create group curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/add_group" \ -H "Content-Type: application/json" \ -H "X-Api-Key: KEY" \ -d '{ "group_name": "My Group", "group_description": "", "pkp_ids_permitted": [], "cid_hashes_permitted": [] }' # Response: {"success":true,"group_id":"1"} # Add action to group (use group_id from the response above) curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/add_action_to_group" \ -H "Content-Type: application/json" \ -H "X-Api-Key: KEY" \ -d '{"group_id": 1, "action_ipfs_cid": "QmYourIpfsCidHere"}' ``` ### 6. Add PKP to group (optional) Restrict which wallets (PKPs) can be used in the group by adding their IDs to the group. ```javascript theme={null} await client.addPkpToGroup({ apiKey: accountApiKey, groupId, // u64 pkpId: walletId // PKP ID from listWallets or createWallet }); ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/add_pkp_to_group" \ -H "Content-Type: application/json" \ -H "X-Api-Key: KEY" \ -d '{"group_id": 1, "pkp_id": "YOUR_PKP_ID"}' ``` ### 7. Run lit-action Execute a lit-action by sending JavaScript code and optional params. Use a usage API key (or account key) in the header. ```javascript theme={null} const result = await client.litAction({ apiKey: usageApiKey, code: ` async function main({ pkpId }) { const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId })); const sig = await wallet.signMessage("Hello from Lit Action"); return { sig }; } `, jsParams: { pkpId: '0x...' } }); console.log(result.response, result.logs); ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/lit_action" \ -H "Content-Type: application/json" \ -H "X-Api-Key: KEY" \ -d '{"code":"async function main() { return \"hello\"; }","js_params":null}' ``` ### Other useful endpoints **Raw CID vs hashed CID:** Some endpoints accept a raw IPFS CID (`action_ipfs_cid`, e.g. `"QmYour..."`) — the server hashes it for you. Other endpoints require the already-hashed CID (`hashed_cid`, e.g. `"0xabc..."`) — a keccak256 hex string you get back from `list_actions`. As a rule: **creation endpoints** (`add_action`, `add_action_to_group`) take the raw CID; **update/delete endpoints** (`delete_action`, `remove_action_from_group`, `update_action_metadata`) take the hashed CID. **Read / list (no billing charge):** * **`GET /list_api_keys?page_number&page_size`** — List usage API keys (paginated). Returns metadata only — key values are not returned. * **`GET /list_groups?page_number&page_size`** — List groups. * **`GET /list_wallets?page_number&page_size`** — List wallets (PKPs). * **`GET /list_wallets_in_group?group_id&page_number&page_size`** — List wallets in a group (`group_id` is a u64). * **`GET /list_actions?[group_id]&page_number&page_size`** — List actions. When `group_id` is provided, lists actions in that group. When omitted, lists all actions on the account. * **`GET /get_node_chain_config`** — Returns chain config, including contract addresses. No auth required. * **`GET /get_api_payers`** — Returns the list of API payer addresses. No auth required. * **`GET /get_admin_api_payer`** — Returns the admin payer address. No auth required. * **`POST /get_lit_action_ipfs_id`** — Compute the IPFS CID for a given JS code string. Body is a JSON string. No auth required. **Mutating management (billed):** * **`POST /remove_usage_api_key`** — Delete a usage key. Body: `{"usage_api_key": "..."}`. * **`POST /update_usage_api_key`** — Update all permissions on an existing usage key. Body: same shape as `add_usage_api_key`, plus `usage_api_key`. * **`POST /update_usage_api_key_metadata`** — Update only the name/description of a usage key. Body: `{"usage_api_key": "...", "name": "...", "description": "..."}`. * **`POST /remove_group`** — Delete a group. Body: `{"group_id": "..."}`. * **`POST /update_group`** — Update group name, description, and permitted PKP IDs / CID hashes. Body: `{"group_id": 1, "name": "...", "description": "...", "pkp_ids_permitted": [], "cid_hashes_permitted": []}`. * **`POST /add_action`** — Register a standalone action (name + description + IPFS CID). Body: `{"action_ipfs_cid": "Qm...", "name": "...", "description": "..."}`. * **`POST /delete_action`** — Delete an action and its metadata from the account. Body: `{"hashed_cid": "0x..."}` (already-hashed CID). * **`POST /remove_action_from_group`** — Remove an IPFS action from a group. Body: `{"group_id": 1, "hashed_cid": "0x..."}` (already-hashed CID). * **`POST /update_action_metadata`** — Update the name/description of a registered action. Body: `{"hashed_cid": "0x...", "name": "...", "description": "..."}`. * **`POST /remove_pkp_from_group`** — Remove a PKP from a group. Body: `{"group_id": 1, "pkp_id": "..."}`. #### Billing * **`GET /billing/stripe_config`** — Returns the Stripe publishable key. No auth required. * **`GET /billing/balance`** — Returns the current credit balance for the authenticated account. * **`POST /billing/create_payment_intent`** — Creates a Stripe PaymentIntent. Body: `{"amount_cents": 500}` (minimum 500 = \$5.00). Returns `client_secret` for use with Stripe.js. * **`POST /billing/confirm_payment`** — Verifies a succeeded PaymentIntent and credits the account. Body: `{"payment_intent_id": "pi_..."}`. ### ChainSecured-mode HTTP endpoints Three HTTP endpoints back the ChainSecured (sovereign) flow. They sit alongside the on-chain writes — see [API mode vs ChainSecured mode](/management/account_modes) for the full SDK-side workflow and when to choose each mode. All three (and the billing-auth header described above) share the same EIP-712 typed-data envelope. The wallet UI displays a labelled struct — `address` and `issuedAt` fields under a stable `(name: "Lit ChainSecured", version: "1", chainId)` domain — so users can see exactly what they're signing. The `primaryType` pins the signature to a specific flow and is part of the EIP-712 type hash, so a signature minted for one endpoint is rejected by every other endpoint at the digest level. Typed-data payloads longer than 4 KiB (serialised JSON) are rejected. | Endpoint | `primaryType` | | ----------------------------------- | ---------------- | | `/create_wallet_with_signature` | `CreateWallet` | | `/convert_to_chain_secured_account` | `ConvertAccount` | | `/add_usage_api_key_with_signature` | `AddUsageApiKey` | | `X-Wallet-Auth` billing header | `BillingAuth` | The canonical typed-data shape (must match exactly — field declaration order is part of the EIP-712 type hash): ```json theme={null} { "types": { "EIP712Domain": [ { "name": "name", "type": "string" }, { "name": "version", "type": "string" }, { "name": "chainId", "type": "uint256" } ], "": [ { "name": "address", "type": "address" }, { "name": "issuedAt", "type": "uint256" } ] }, "primaryType": "", "domain": { "name": "Lit ChainSecured", "version": "1", "chainId": "8453" }, "message": { "address": "0x…", "issuedAt": "" } } ``` The server enforces a ±5-minute window on `issuedAt` as the only replay protection — no nonce store. Worst-case replay on the unauthenticated mint endpoints just produces an extra unattached PKP (compute cost only — see each endpoint below for specifics). #### `POST /create_wallet_with_signature` Mints a PKP via DStack MPC after verifying a wallet-ownership signature. Used by `createWallet` in ChainSecured mode; the response is then passed to the on-chain `registerWalletDerivation` call (signed by the same wallet) to register the PKP to the account. No API key required. `primaryType` must be `CreateWallet`. **Request body:** ```json theme={null} { "typed_data": { /* canonical EIP-712 typed data with primaryType: "CreateWallet" */ }, "signature": "0x<65-byte hex>" } ``` **Response:** ```json theme={null} { "wallet_address": "0x...", "derivation_path": "0x..." } ``` Pass `derivation_path` verbatim into `registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)` on the AccountConfig contract — until that lands, the PKP exists in MPC but is not registered to any account. ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/create_wallet_with_signature" \ -H "Content-Type: application/json" \ -d '{ "typed_data": { "types": { "EIP712Domain": [ {"name":"name","type":"string"}, {"name":"version","type":"string"}, {"name":"chainId","type":"uint256"} ], "CreateWallet": [ {"name":"address","type":"address"}, {"name":"issuedAt","type":"uint256"} ] }, "primaryType": "CreateWallet", "domain": {"name":"Lit ChainSecured","version":"1","chainId":"8453"}, "message": {"address":"0xabc...","issuedAt":"1745798400"} }, "signature": "0x..." }' ``` #### `POST /convert_to_chain_secured_account` Flips a managed (API-mode) account to ChainSecured (unmanaged) in a single on-chain transaction. The account's `apiKeyHash` is preserved — groups, PKPs, action metadata, and usage API keys (everything keyed by `apiKeyHash` on-chain) stay attached. Only the admin wallet and the `managed` flag change on-chain. **Billing should be re-verified after conversion:** Stripe credits are associated with the Stripe customer resolved from the current admin wallet address, so a credit balance is not guaranteed to carry over automatically when the admin wallet changes. There is no reverse path. Requires the existing master API key (sent via `X-Api-Key` or `Authorization: Bearer`, per the auth header conventions above). The server's api\_payer signs the on-chain conversion after verifying the wallet signature; the new admin must sign EIP-712 typed data with `primaryType: "ConvertAccount"`. **Request body:** ```json theme={null} { "new_admin_wallet_address": "0x...", "typed_data": { /* primaryType: "ConvertAccount" */ }, "signature": "0x..." } ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/convert_to_chain_secured_account" \ -H "Content-Type: application/json" \ -H "X-Api-Key: KEY" \ -d '{ "new_admin_wallet_address":"0xabc...", "typed_data": { "types": { "EIP712Domain": [ {"name":"name","type":"string"}, {"name":"version","type":"string"}, {"name":"chainId","type":"uint256"} ], "ConvertAccount": [ {"name":"address","type":"address"}, {"name":"issuedAt","type":"uint256"} ] }, "primaryType": "ConvertAccount", "domain": {"name":"Lit ChainSecured","version":"1","chainId":"8453"}, "message": {"address":"0xabc...","issuedAt":"1745798400"} }, "signature":"0x..." }' ``` See [API mode vs ChainSecured mode → Converting an API account to ChainSecured](/management/account_modes#converting-an-api-account-to-chainsecured) for the dashboard flow, the SDK wrapper (`client.convertToChainSecuredAccount`), and the post-conversion verification checklist. #### `POST /add_usage_api_key_with_signature` The ChainSecured counterpart to `/add_usage_api_key`. Mirrors `create_wallet_with_signature`: the server mints a usage-key wallet via DStack MPC after verifying a wallet-ownership signature, then returns the secret (base64-encoded) plus the wallet address and derivation path. The client follows up on-chain — only the admin wallet of a ChainSecured account can call `setUsageApiKey`, so the server cannot complete the attach for you. No API key required. `primaryType` must be `AddUsageApiKey`. Worst-case replay just returns a fresh secret for an unattached wallet — equivalent to a freshly generated keypair until the admin wallet calls the on-chain follow-ups, so compute cost only. **Request body:** ```json theme={null} { "typed_data": { /* primaryType: "AddUsageApiKey" */ }, "signature": "0x<65-byte hex>" } ``` **Response:** ```json theme={null} { "usage_api_key": "", "wallet_address": "0x...", "derivation_path": "0x..." } ``` The client must do two on-chain calls signed by the admin wallet to attach the new usage key: 1. `registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)` — registers the PKP to the account. 2. `setUsageApiKey(adminHash, keccak256(usage_api_key_bytes), expiration, balance, name, description, …permissions)` — attaches the usage key with its permission set. Until both land, the secret in `usage_api_key` is just a freshly minted keypair with no on-chain identity. ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/add_usage_api_key_with_signature" \ -H "Content-Type: application/json" \ -d '{ "typed_data": { "types": { "EIP712Domain": [ {"name":"name","type":"string"}, {"name":"version","type":"string"}, {"name":"chainId","type":"uint256"} ], "AddUsageApiKey": [ {"name":"address","type":"address"}, {"name":"issuedAt","type":"uint256"} ] }, "primaryType": "AddUsageApiKey", "domain": {"name":"Lit ChainSecured","version":"1","chainId":"8453"}, "message": {"address":"0xabc...","issuedAt":"1745798400"} }, "signature":"0x..." }' ``` *** Both request/response shapes and OpenAPI spec are available directly in the dev system. For a Swagger UI implementation of the OpenAPI spec, please browse to:\ \ [https://api.chipotle.litprotocol.com/core/v1/swagger-ui](https://api.chipotle.litprotocol.com/core/v1/swagger-ui) ### Open API Specification The OpenAPI spec itself can be found at:\ \ [https://api.chipotle.litprotocol.com/core/v1/openapi.json](https://api.chipotle.litprotocol.com/core/v1/openapi.json) Note that these specs are subject to minor changes and will always be available with the dev server endpoints. # API Keys Source: https://developer.litprotocol.com/management/api_keys Understanding account keys and usage keys in Lit Chipotle. ## API Keys Lit Chipotle uses two distinct types of API keys, each with a different scope and purpose. *** ### Account Key Your account key is created once, at account creation time. It is the master credential for your account — treat it like a password. * **Created:** Automatically generated when you create a new account. Displayed **once** in a one-time success message; copy and store it immediately. * **Purpose:** Full administrative access to your account — creating and deleting usage keys, managing groups, registering actions, and creating PKPs. * **Authentication:** Pass it in the `X-Api-Key` (or `Authorization: Bearer`) header to authenticate as the account owner. * **Security:** Because this key is your master credential, it should never be embedded in client-side code, shared with users, or rotated casually. If it is compromised, your entire account is at risk. Store it in a secrets manager or equivalent secure store. The account key is shown only once at creation. There is no way to retrieve it again. If it is lost, you will need to contact support. *** ### Usage Keys Usage keys are scoped, rotatable keys intended for day-to-day operations — for use in dApps, servers, cron jobs, or anywhere you need to run lit-actions without exposing your master credential. * **Created:** From the **Usage API Keys** section of the dashboard, or via the API. Like the account key, each usage key is shown **once** on creation. * **Purpose:** Running lit-actions and interacting with the node on behalf of your account. Access is enforced through groups — a usage key can only perform operations in the groups it has been explicitly granted access to. * **Authentication:** Pass the usage key in the `X-Api-Key` (or `Authorization: Bearer`) header just as you would the account key. * **Security model:** Usage keys enforce least-privilege access. By scoping each key to specific groups (and therefore specific IPFS actions and PKPs), you can give a key to a client or service without granting it access to your full account. If a key is compromised or no longer needed, delete it — this has no impact on other keys or your account. #### Key lifecycle | Action | Who can perform it | | ---------------------------- | ------------------------------------------------------- | | Create usage key | Account key only | | Update usage key permissions | Account key only | | Delete usage key | Account key only | | Run a lit-action | Account key or usage key (subject to group permissions) | *** ### Managing Usage Keys Usage keys can be managed through the [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/) or directly via the REST API. Both require your account key to authenticate. #### Via the Dashboard In the **Usage API Keys** section of the [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/): * **Add** — Click **Add**, optionally set a name and description, then confirm. The new key is displayed once — copy it immediately. * **Delete** — Select a key and delete it. This takes effect immediately; any service still using the key will receive authentication errors. For a full walkthrough of the dashboard workflow, see [Using the Dashboard](/management/dashboard#3-request-usage-api-keys). #### Via the API All usage key management endpoints are under `/core/v1/` and require your account key in the `X-Api-Key` (or `Authorization: Bearer`) header. *** **Create a usage key** — `POST /core/v1/add_usage_api_key` Returns the new key once in the response (`usage_api_key`). Permissions are set at creation time — pass empty arrays to grant no group access initially. ```javascript theme={null} const res = await client.addUsageApiKey({ apiKey: accountApiKey, name: 'My dApp Key', description: 'Executes price-feed action', canCreateGroups: false, canDeleteGroups: false, canCreatePkps: false, manageIpfsIdsInGroups: [], // group IDs; [0] = wildcard for all groups addPkpToGroups: [], removePkpFromGroups: [], executeInGroups: [1] // allow execution in group ID 1 }); console.log('New usage key (store now):', res.usage_api_key); ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/add_usage_api_key" \ -H "Content-Type: application/json" \ -H "X-Api-Key: YOUR_ACCOUNT_KEY" \ -d '{ "name": "My dApp Key", "description": "Executes price-feed action", "can_create_groups": false, "can_delete_groups": false, "can_create_pkps": false, "manage_ipfs_ids_in_groups": [], "add_pkp_to_groups": [], "remove_pkp_from_groups": [], "execute_in_groups": [1] }' ``` **Permission fields:** | Field | Type | Description | | --------------------------- | ------- | --------------------------------------------------------------------------------------------- | | `name` | string | Human-readable label for the key | | `description` | string | Optional description | | `can_create_groups` | bool | Allow this key to create new groups | | `can_delete_groups` | bool | Allow this key to delete groups | | `can_create_pkps` | bool | Allow this key to create PKPs | | `manage_ipfs_ids_in_groups` | `u64[]` | Group IDs where this key can add/remove IPFS actions. Use `[0]` as a wildcard for all groups. | | `add_pkp_to_groups` | `u64[]` | Group IDs where this key can add PKPs. Use `[0]` for all groups. | | `remove_pkp_from_groups` | `u64[]` | Group IDs where this key can remove PKPs. Use `[0]` for all groups. | | `execute_in_groups` | `u64[]` | Group IDs where this key can execute lit-actions. Use `[0]` for all groups. | *** **List usage keys** — `GET /core/v1/list_api_keys?page_number=0&page_size=20` Returns a paginated list of usage keys on the account. The key value itself is not returned — only its hash and metadata. Each item includes the full permission set as it exists on-chain. ```javascript theme={null} const keys = await client.listApiKeys({ apiKey: accountApiKey, pageNumber: 0, pageSize: 20 }); // Each item: { id, api_key_hash, name, description, expiration, balance, // can_create_groups, can_delete_groups, can_create_pkps, // can_manage_ipfs_ids_in_groups, can_add_pkp_to_groups, // can_remove_pkp_from_groups, can_execute_in_groups } console.log(keys); ``` ```bash theme={null} curl -s "https://api.chipotle.litprotocol.com/core/v1/list_api_keys?page_number=0&page_size=20" \ -H "X-Api-Key: YOUR_ACCOUNT_KEY" ``` *** **Update a usage key's permissions** — `POST /core/v1/update_usage_api_key` Replaces all permissions on an existing usage key. Pass the usage key value (not the account key) in the body. The full permission set must be provided — any fields omitted will be reset to their defaults. ```javascript theme={null} await client.updateUsageApiKey({ apiKey: accountApiKey, usageApiKey: 'THE_USAGE_KEY_VALUE', name: 'My dApp Key', description: 'Now also manages groups', canCreateGroups: true, canDeleteGroups: false, canCreatePkps: false, manageIpfsIdsInGroups: [1], addPkpToGroups: [], removePkpFromGroups: [], executeInGroups: [1] }); ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/update_usage_api_key" \ -H "Content-Type: application/json" \ -H "X-Api-Key: YOUR_ACCOUNT_KEY" \ -d '{ "usage_api_key": "THE_USAGE_KEY_VALUE", "name": "My dApp Key", "description": "Now also manages groups", "can_create_groups": true, "can_delete_groups": false, "can_create_pkps": false, "manage_ipfs_ids_in_groups": [1], "add_pkp_to_groups": [], "remove_pkp_from_groups": [], "execute_in_groups": [1] }' ``` *** **Update a usage key's name/description only** — `POST /core/v1/update_usage_api_key_metadata` Updates only the name and description without touching permissions. ```javascript theme={null} await client.updateUsageApiKeyMetadata({ apiKey: accountApiKey, usageApiKey: 'THE_USAGE_KEY_VALUE', name: 'Renamed Key', description: 'Updated description' }); ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/update_usage_api_key_metadata" \ -H "Content-Type: application/json" \ -H "X-Api-Key: YOUR_ACCOUNT_KEY" \ -d '{ "usage_api_key": "THE_USAGE_KEY_VALUE", "name": "Renamed Key", "description": "Updated description" }' ``` *** **Delete a usage key** — `POST /core/v1/remove_usage_api_key` Permanently removes a usage key. Pass the key value (not an ID) in the request body. Takes effect immediately. ```javascript theme={null} await client.removeUsageApiKey({ apiKey: accountApiKey, usageApiKey: 'THE_USAGE_KEY_VALUE' }); ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/remove_usage_api_key" \ -H "Content-Type: application/json" \ -H "X-Api-Key: YOUR_ACCOUNT_KEY" \ -d '{"usage_api_key": "THE_USAGE_KEY_VALUE"}' ``` For the full API reference and all available endpoints, see [Using the API directly](/management/api_direct) or browse the [Swagger UI](https://api.chipotle.litprotocol.com/core/v1/swagger-ui). *** ### Comparison | | Account Key | Usage Key | | -------------- | -------------------------- | ------------------------------ | | Created | At account creation | On demand | | Scope | Full account access | Group-scoped | | Rotatable | No (it is your identity) | Yes — create and delete freely | | Intended for | Secure admin contexts only | dApps, services, automation | | Risk if leaked | Full account compromise | Limited to permitted groups | # Crypto Payments Source: https://developer.litprotocol.com/management/crypto How to add funds to your Lit Chipotle account using cryptocurrency via Stripe. ## Overview Lit Chipotle supports purchasing credits with cryptocurrency through Stripe's crypto payment integration. You can pay with **ETH**, **USDC**, **SOL**, and other supported tokens directly from your wallet — no fiat currency or credit card required. Under the hood, Stripe converts the crypto payment into credits on your account using the same billing endpoints as card payments. *** ## Paying with Crypto via the Dashboard The simplest way to add funds with crypto: 1. Log in to the [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/). 2. Click **Add Funds** in the top-right corner. 3. Select a credit package (see [Credit Packages](/management/pricing#credit-packages)). 4. On the Stripe checkout page, choose **Crypto** as the payment method. 5. Connect your wallet (MetaMask, Coinbase Wallet, or WalletConnect) and approve the transaction. Credits are applied to your account once the transaction is confirmed on-chain. Crypto payments are processed by Stripe and are subject to Stripe's supported tokens and networks. Check [Stripe's crypto documentation](https://docs.stripe.com/crypto/pay-with-crypto) for the latest supported assets. *** ## Paying with Crypto via the API You can also initiate crypto payments programmatically using the billing API. The flow mirrors the standard Stripe card payment flow, but you pass crypto-specific parameters when confirming on the client side. Examples below assume the following setup: ```javascript theme={null} const BASE = 'https://api.chipotle.litprotocol.com'; // or https://api.dev.litprotocol.com for dev const accountApiKey = 'your-account-api-key'; // from /new_account ``` cURL snippets use `$KEY` for your API key. ### Step 1: Get the Stripe publishable key ```javascript theme={null} const res = await fetch(`${BASE}/core/v1/billing/stripe_config`); const { publishable_key } = await res.json(); ``` ```bash theme={null} curl -s "https://api.chipotle.litprotocol.com/core/v1/billing/stripe_config" ``` ### Step 2: Create a PaymentIntent Create a PaymentIntent for the desired amount (minimum 500 cents = \$5.00). ```javascript theme={null} const res = await fetch(`${BASE}/core/v1/billing/create_payment_intent`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': accountApiKey, }, body: JSON.stringify({ amount_cents: 2500 }), // $25.00 }); const { client_secret, payment_intent_id } = await res.json(); // Use `client_secret` in Step 3 and `payment_intent_id` in Step 4. ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/billing/create_payment_intent" \ -H "Content-Type: application/json" \ -H "X-Api-Key: $KEY" \ -d '{"amount_cents": 2500}' # Response: {"client_secret":"pi_...","payment_intent_id":"pi_..."} ``` ### Step 3: Confirm the payment with Stripe.js (crypto) Use the `client_secret` returned from Step 2 with [Stripe.js](https://docs.stripe.com/js) to present the crypto payment option. Stripe handles wallet connection and on-chain transaction signing. ```javascript theme={null} import { loadStripe } from '@stripe/stripe-js'; const stripe = await loadStripe(publishable_key); // Mount the Payment Element — it automatically shows crypto options // when available for your Stripe account. const elements = stripe.elements({ clientSecret: client_secret }); const paymentElement = elements.create('payment'); paymentElement.mount('#payment-element'); // When the user submits the form: const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: 'https://dashboard.chipotle.litprotocol.com/dapps/dashboard/', }, }); if (error) { console.error('Payment failed:', error.message); } ``` The `return_url` is where Stripe redirects after the on-chain transaction completes. Make sure it points to a page that calls the confirm endpoint (Step 4) to finalize the credit top-up. ### Step 4: Confirm payment and credit the account After Stripe confirms the crypto payment has settled, call the confirm endpoint to apply credits. ```javascript theme={null} const res = await fetch(`${BASE}/core/v1/billing/confirm_payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': accountApiKey, }, body: JSON.stringify({ payment_intent_id }), }); const result = await res.json(); console.log('Credits applied:', result); ``` ```bash theme={null} curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/billing/confirm_payment" \ -H "Content-Type: application/json" \ -H "X-Api-Key: $KEY" \ -d '{"payment_intent_id": "pi_..."}' ``` ### Step 5: Verify your balance ```javascript theme={null} const res = await fetch(`${BASE}/core/v1/billing/balance`, { headers: { 'X-Api-Key': accountApiKey }, }); const { balance_cents, balance_display } = await res.json(); console.log('Current balance:', balance_display, `(${balance_cents} cents)`); ``` ```bash theme={null} curl -s "https://api.chipotle.litprotocol.com/core/v1/billing/balance" \ -H "X-Api-Key: $KEY" ``` *** ## Supported Tokens and Networks Stripe's crypto payment support includes the following (subject to change): | Token | Networks | | -------- | ------------------------- | | **USDC** | Ethereum, Solana, Polygon | | **USDP** | Ethereum | | **ETH** | Ethereum | | **SOL** | Solana | Stripe automatically handles the conversion from crypto to USD at the current exchange rate. The credit amount you receive matches the USD value of the package you selected — there are no additional conversion fees from Lit. *** ## Frequently Asked Questions **How long does it take for credits to appear?**\ Credits are applied after the on-chain transaction reaches sufficient confirmations. For most networks this takes 1-5 minutes. Stripe handles the confirmation monitoring automatically. **Is there a minimum payment?**\ Yes, the same \$5.00 minimum (500 cents) applies to crypto payments, matching the Starter package. **What wallets are supported?**\ Any wallet compatible with Stripe's crypto on-ramp, including MetaMask, Coinbase Wallet, and WalletConnect-compatible wallets. **What if my transaction fails or reverts?**\ If the on-chain transaction fails, no credits are deducted and no charge is applied. You can retry the payment. **Can I get a refund in crypto?**\ Refund policies follow the same terms as card payments. Contact the Lit Protocol team via [Discord](https://litgateway.com/discord) for refund requests. # Dashboard Source: https://developer.litprotocol.com/management/dashboard ## Using the Dashboard The [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/) is a web management GUI for Lit's Chipotle offering. Open it from your browser at `https://dashboard.chipotle.litprotocol.com/dapps/dashboard/` It supports light/dark theme for your convenience and provides simple management tools. **Dashboard workflow (recommended order):** 1. [Request a new account (or log in)](#1-request-a-new-account-or-log-in) 2. [Add funds](#2-add-funds) 3. [Request usage API keys](#3-request-usage-api-keys) 4. [Request new PKPs (wallets)](#4-request-new-pkps-wallets) 5. [Register IPFS CIDs (actions)](#5-register-ipfs-cids-actions) 6. [Create groups](#6-create-groups) 7. [Run lit-actions](#7-run-lit-actions) ### 1. Request a new account (or log in) On the login page you have two tabs: * **Existing User** — Paste your account API key and click **Log in**. The server checks that the account exists and is mutable. * **New User** — Enter an account name and an optional description, then click **Create account**. The server creates the account and displays your new API key and wallet address in a one-time success message. **Copy and store the API key immediately**; it is shown only once and you'll need it to manage the account. After login, the dashboard shows Overview, Usage API Keys, Groups, IPFS Actions, Wallets, and Action Runner.\ You'll notice a single wallet in your account - it represents your master account API key, and can be used like a standard EVM wallet, or you can safely skip its web3 properties and use the APIs directly. ### 2. Add funds Running Lit Actions and metered/write management operations (such as creating, updating, or deleting keys, PKPs, groups, or IPFS actions) requires credits. Read-only dashboard and API operations (for example, viewing keys, balances, or usage) are free. Click **Add Funds** in the top-right corner of the Dashboard to purchase credits with a credit card via Stripe. Select a credit package (minimum \$5.00), enter your card details, and click **Pay**. Credits are applied to your account immediately. See [Pricing](/management/pricing) for credit packages and cost details. ### 3. Request usage API keys Usage API keys are scoped keys you can give to clients or dApps to run specific lit-actions or to deploy. They can be rotated or removed without changing the main account. In the **Usage API Keys** section, click **Add**. Set an optional name and description, then click *Confirm*. The server generates a new usage key and displays it in a one-time success message — **copy and store it immediately**, as it will not be shown again. \ \ Use this key in the `X-Api-Key` (or `Authorization: Bearer`) header when calling the node so that usage is attributed to this key. ### 4. Request new PKPs (wallets) PKPs (Programmable Key Pairs) are wallets the lit-nodes can use for signing. In the **Wallets** section, click **Add** to create a new wallet to assign to one of your users, or for use in running a lit-action. The server generates a new PKP and returns its address and public key. You can add existing PKPs to groups (see step 6) via **Add PKP to group** in the Groups section. ### 5. Register IPFS CIDs (actions) To scope which usage API keys can run which code, you register **IPFS CIDs** as permitted actions. In the **IPFS Actions** section, pick a group from the dropdown, then **Add** an action: enter the IPFS CID of the lit-action and optional name/description. The server hashes the CID and stores it in the group. Only keys that are allowed to use that group can run that action. ### 6. Create groups Groups logically combine PKPs, IPFS actions, and (indirectly) usage API keys. You can use any combination: e.g., a group with only permitted actions, or only permitted wallets, or both. In the **Groups** section, click **Add** to create a group (name, description, optional permitted actions and PKPs, and flags for "all wallets permitted" / "all actions permitted"). Then: * Use **IPFS Actions** to add CIDs to the group. * Use **Add PKP to group** / **Remove PKP from group** to allow which wallets can be used in that group. Usage API keys (and the account key) are validated against the account's groups and permitted actions/wallets when you run a lit-action. ### 7. Run lit-actions In the **Action Runner** section, paste some Lit Action JavaScript code and optional JSON parameters. For example ```js theme={null} async function main({ pkpId }) { const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId })); const sig = await wallet.signMessage("Hello from Lit Action"); return { sig }; } ``` Choose the API key (account or usage key) to use for the request, then click **Execute**. The node runs the action and returns signatures, response, and logs. The key you use must be allowed to run that action (via the group and IPFS CID configuration). ## Daily Usage The dashboard is just your human-friendly configuration tool. Once your account and keys are set up to your liking, you can simply call the lit-action endpoint with your usage key each time you, your dApp or cron job needs to execute a lit action. So the only daily use step is 1. Call the API with your usage key, action-code ( or IPFS CID ) and any parameters that you need # Pricing Source: https://developer.litprotocol.com/management/pricing Credit-based pricing for Lit Chipotle — management operations, Lit Action execution, and how to top up your account. ## Overview Lit Chipotle uses a **credit-based billing model**. Credits are pre-purchased and drawn down as you use the API. There are no subscriptions, no per-seat fees, and no charges for read-only operations. *** ## What's Free All **read-only** dashboard and API operations are free of charge. These include: * Viewing groups, wallets, and IPFS actions * Listing usage API keys * Checking your account balance * Any `GET` request that does not modify on-chain state *** ## Metered Operations The following operations consume credits each time they are called: | Operation | Cost | | ----------------------------------------------------------------------------- | -------------------------- | | Management call (create/update/delete group, wallet, action, usage key, etc.) | \*\*\$0.01 per second \*\* | | Lit Action execution | \*\*\$0.01 per second \*\* | Management calls include: `create_wallet`, `add_group`, `remove_group`, `add_action`, `delete_action`, `add_action_to_group`, `remove_action_from_group`, `update_group`, `update_action_metadata`, `add_pkp_to_group`, `remove_pkp_from_group`, `add_usage_api_key`, `remove_usage_api_key`, `update_usage_api_key`, `update_usage_api_key_metadata`. Note that while management calls may take several seconds to respond while Chipotle confirms blocks, there is no charge for this wait time - management calls are effectively 1 second. Common features like signing generally take less than a second to execute, and thus standard ECDSA signatures ( used for common blockchains and bitcoin transactions ) are effectively charged at \$0.01 USD. *** ## Purchasing Credits ### Paying with a Credit Card (via Stripe) Credits can be purchased directly in the dashboard using a credit card. Stripe processes the payment — your card details are sent directly to Stripe and are never stored on Lit's servers. **To add funds:** 1. Log in to the [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/). 2. Click **Add Funds** in the top-right corner. 3. Select a credit package from the table below. 4. Enter your card details and click **Pay**. Credits are applied to your account immediately after payment is confirmed. ### Credit Packages | Package | Price | Credits included | | -------- | ----------- | ---------------- | | Starter | **\$5.00** | 500 credits | | Basic | **\$10.00** | 1,000 credits | | Standard | **\$25.00** | 2,500 credits | | Pro | **\$50.00** | 5,000 credits | The minimum top-up is **\$5.00**. All packages are one-time purchases with no expiry. ### Paying with Crypto You can pay with cryptocurrency (ETH, USDC, SOL, and other tokens) via Stripe's crypto payment integration. See the [Crypto Payments](/management/crypto) guide for full instructions on paying from the dashboard or via the API. *** ## Credit Balance Your current balance is always visible in the top-right corner of the dashboard once you're logged in. A negative balance (displayed as a credit) means funds are available. Credits are depleted as you make metered API calls. If a call is made when your balance is exhausted, the API returns a `402 Payment Required` error. Top up your account to resume normal operation. *** ## Billing Identity Your billing account is tied to the **wallet address derived from your account API key**. This wallet address is used as the Stripe customer identifier. If you provide an email address when creating your account, it is forwarded to Stripe for your customer record and for payment receipts.