# Authentication Model
Source: https://docs.dev.litprotocol.com/architecture/authModel
How accounts, API keys, PKPs, groups, and the TEE root key compose into a programmable KMS — and how API mode vs ChainSecured mode emerges from configuration.
## Core Insight
ChainSecured mode and API mode aren't a toggle in the code — they're an emergent property of how you configure the same system. The only things that vary are:
1. **Who is the Account Owner** (a Lit-managed credential vs a SAFE or EOA you control)
2. **What scopes the API keys have** (everything vs execute-only vs somewhere in between)
A "ChainSecured-mode" setup is just: owner = a wallet you control (EOA/Safe), API keys = execute-only. An "API-mode" setup is just: owner = a Lit-managed credential, 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 Lit-managed credential (API mode — the fast path: Lit holds the owner key and relays your writes)
* A SAFE or any governance contract (ChainSecured mode — 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 API mode, developers need these to build their app through the API. In ChainSecured 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, ← API mode: broad scopes
│ │ group:delete, group:manage*(*)]
│ ├── key_server: [execute(group_1)] ← ChainSecured: execution only
│ └── key_onboard: [pkp:create, group:addPkp(group_1)] ← ChainSecured: 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 ChainSecured-mode 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 ChainSecured Mode Emerges from Configuration
Consider two setups:
### Setup A: "API mode"
* **Owner:** a Lit-managed credential (the fast path)
* **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 dashboard). This is the full development experience — create groups, add actions, create PKPs, execute, iterate. Fast iteration, no multisig overhead. Recovery is re-authenticating to the dashboard. 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: "ChainSecured 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 API 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 ChainSecured 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 (API mode)
1. User signs up through the dashboard
2. The account is created with a Lit-managed owner credential → 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 ChainSecured mode
1. User deploys a SAFE on Base with their desired signer set
2. User calls `transferOwnership(safeAddress)` — authorized by the current owner through the dashboard
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. The managed credential is fully out of the loop — the SAFE is the sole on-chain owner
### Key rotation
Owner registers a new API key address with the desired scopes, then revokes the old one. In API mode, this is an HTTP call (the managed credential authorizes it). In ChainSecured mode, this requires a SAFE vote.
***
# Chain Secured
Source: https://docs.dev.litprotocol.com/architecture/chain-secured
A key's authority lives in smart contracts on Base, and an attested TEE enforces those rules by reading the chain — on-chain authority, signing at API speed.
Lit's core guarantee is simple to state: **a key's authority is on-chain state.** What a key may sign, which code is allowed to use it, and who can change those rules all live in smart contracts on Base. Nothing off-chain — no server, no API key, no operator, not even Lit — can make a key sign outside the rules currently on the chain.
We call this **Chain Secured**.
## The chain holds the authority
Authorization isn't a flag in someone's database. It's on-chain state that you own:
* **Account** — an address on Base that owns everything: a wallet you control (an EOA or Safe) in **ChainSecured mode**, or a Lit-managed credential in **API mode**. See [API mode vs ChainSecured mode](/management/account_modes).
* **API keys & scopes** — each key is registered on-chain with explicit, per-group scopes (`execute`, `group:addPkp`, and so on).
* **PKPs & Groups** — a Group is the authorization policy: it binds the keys (PKPs) to the immutable Lit Actions (IPFS CIDs) allowed to use them.
The contract is the authority. Every privileged operation comes down to one check:
```solidity theme={null}
require(
msg.sender == accountOwner ||
isAPIKeyWithScope(msg.sender, requiredScope, groupId)
);
```
Changing a rule is an on-chain transaction — public, auditable, and authorized by an owner you control. Put a Safe in the owner slot and it takes a multisig vote. See the [Authentication Model](/architecture/authModel) for the full permission matrix.
## An attested TEE enforces it — by reading
On-chain rules are only as strong as what enforces them. In Lit, that's a sealed TEE that **reads** the contracts on every request:
1. A request arrives: an API key, and "run action `QmABC` with `pkp_001`."
2. The TEE reads on-chain — does this key have `execute` scope on a group where both `QmABC` and `pkp_001` are listed?
3. If yes, it derives the key inside the enclave, runs the action, and signs. If no, it refuses.
Key material never leaves the enclave, and the enclave can only run code that on-chain governance has whitelisted — so the read is trustworthy, not just convenient. You can verify the exact enclave yourself: see [Security & Verification](/architecture/verification/index) and [On-Chain KMS](/architecture/verification/onchain-kms).
## Why it scales: write the rules, read to enforce
The split between authority and enforcement is the point:
* **Setting or changing a rule is a write** — one on-chain transaction, only when you configure or govern.
* **Using a rule is a read** — the TEE reads current on-chain state and signs in the enclave. No per-operation transaction, no gas, no waiting on a block.
So you get the chain's guarantees — public, owner-controlled, auditable authority — without paying the chain's throughput cost on every signature. Use smart-contract rails where they create trust, and reads where you need speed. The result is authority on-chain, signing at the speed of an API call.
## What Chain Secured locks down
Because authority lives on-chain and the TEE only ever enforces it:
* A leaked execute-only key can invoke the actions it is already permitted to, and nothing else. It cannot add itself to new groups, swap in new code, or move funds outside policy.
* Changing *what the rules are* requires an owner transaction on Base. Put the owner behind a Safe, and an evil admin holding an execute key can invoke existing actions but cannot change the rules.
* The rules are visible to anyone, at any time. Don't trust — verify.
## Further reading
* [Authentication Model](/architecture/authModel) — entities, scopes, and the full permission matrix
* [System Diagram](/architecture/diagram) — on-chain vs TEE boundaries and management paths
* [On-Chain KMS](/architecture/verification/onchain-kms) — how Base contracts gate root-key release
* [Security & Verification](/architecture/verification/index) — attestation and the full chain of trust
# Entity Relationships
Source: https://docs.dev.litprotocol.com/architecture/diagram
Entity relationships, on-chain vs TEE boundaries, and how API mode vs ChainSecured mode emerges from configuration.
**Core Insight:** API mode and ChainSecured mode aren't a toggle in the code — they're an **emergent property** of how you configure the same system. The only things that vary are **who owns the account** (a Lit-managed credential vs a SAFE or EOA you control) 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. A SAFE or EOA you control (ChainSecured mode), or a Lit-managed credential (API mode). **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 / Lit-managed ──▶ 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 *(ChainSecured mode)*
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.
### API Mode
*Fast iteration, broad scopes, managed recovery*
| | |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Owner** | A Lit-managed credential |
| **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 the dashboard. Recovery = re-authenticating to the dashboard. |
### ChainSecured 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://docs.dev.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://docs.dev.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 throughline: **your keys' authority lives on-chain, and an attested TEE enforces it by reading the chain.** We call this [Chain Secured](/architecture/chain-secured) — on-chain authority, signing at the speed of an API call.
## 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.
## API mode vs ChainSecured mode
Who owns the account is a configuration choice, not a fork in the code. In **API mode**, a Lit-managed credential owns the account and relays your admin writes — the fastest way to start. In **ChainSecured mode**, a wallet you control (an EOA or Safe) owns the account on-chain and signs every change itself — fully self-custodied, with an on-chain audit trail. Both run the same contracts and the same Lit Actions; only account ownership and how writes are signed differ.
See [API Mode vs ChainSecured Mode](/management/account_modes) for the side-by-side and the migration path.
## Further Reading
* [Chain Secured](/architecture/chain-secured) — why your keys' authority lives on-chain, and how an attested TEE enforces it by 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
# Self-Hosting
Source: https://docs.dev.litprotocol.com/architecture/self-hosting
How to think about self-hosting Lit Chipotle, what is open source, and the operational tradeoffs compared with the hosted service.
Lit Chipotle is open source. The API server, Lit Actions runtime, dashboard/static assets, contracts, examples, and local development tooling are available in public repositories so teams can audit the stack, run it locally, and operate their own deployment when they need that level of control.
Self-hosting is most useful when you need infrastructure ownership, private deployment controls, custom compliance boundaries, or a deployment model that your users can independently verify. The hosted Lit service is still the fastest path for most teams because Lit operates the TEE infrastructure, deployment pipeline, monitoring, billing integration, and upgrades.
Email the Lit team with your deployment goals, expected traffic, security requirements, and target environment.
## Open source repositories
The main stack lives in the Chipotle repository:
Core API server, Lit Actions runtime integration, dashboard/static assets, smart contracts, deployment files, examples, and docs.
Runnable example apps for signing, encrypted policies, oracles, private stablecoins, cross-chain flows, and more.
The action execution runtime used by the API server to run JavaScript inside the TEE-backed environment.
The Rocket-based HTTP service that exposes account management, Lit Action execution, attestation, billing, and configuration endpoints.
The browser UI for account, key, wallet, action, group, and billing management.
The open-source TEE stack used for local simulation and Phala Cloud deployments.
## What you can self-host
There are two common levels of ownership:
| Model | What you operate | Best for |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **Local development** | Anvil, dstack simulator, contracts, `lit-api-server`, `lit-actions`, and static dashboard from your workstation. | Development, testing, demos, and auditing behavior before deploying. |
| **Production self-hosting** | A TEE-backed deployment, Docker images, API server, Lit Actions runtime, chain configuration, RPC access, monitoring, release process, and verification evidence. | Teams that need infrastructure control, custom compliance review, private environments, or independently managed uptime. |
For local development, start with the repository's `README.md` and `local_test.sh`. For production-oriented deployment details, see the deployment and verification references below.
## Where you can deploy
Lit Chipotle runs anywhere [dstack](https://github.com/Dstack-TEE/dstack) runs. dstack is Docker Compose native, so the same images and compose files the hosted service uses deploy unchanged across any supported platform:
| Platform | Status | Attestation |
| -------------------------------------------------------- | --------- | ----------- |
| **Bare metal Intel TDX** | Available | TDX |
| **Bare metal AMD SEV-SNP** | Available | SEV-SNP |
| **[Phala Cloud](https://cloud.phala.network)** (managed) | Available | TDX |
| **GCP Confidential VMs** | Available | TDX + TPM |
| **AWS Nitro Enclaves** | Available | NSM |
dstack also supports NVIDIA Confidential Computing (H100, Blackwell) for confidential GPU workloads alongside the CPU TEE. This list tracks the [dstack supported platforms table](https://github.com/Dstack-TEE/dstack#supported-platforms) — as dstack adds platforms, they become self-hosting targets for Lit Chipotle. For preparing your own TDX or SEV-SNP host, see dstack's [hardware enablement](https://github.com/Dstack-TEE/dstack/blob/master/docs/hardware-enablement.md) and [self-hosted onboarding](https://github.com/Dstack-TEE/dstack/blob/master/docs/onboarding.md) guides.
## Tradeoffs
Self-hosting gives you more control, but it moves more responsibility onto your team.
| Area | Hosted Lit service | Self-hosted deployment |
| -------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| **Time to production** | Fastest path; create an account and call the API. | Requires infrastructure setup, release automation, configuration, and operations. |
| **Infrastructure control** | Lit operates the public service. | You choose the TEE provider, deployment topology, domain model, and operational controls. |
| **Upgrades** | Lit rolls out service upgrades. | You decide when to upgrade and are responsible for testing, rollout, and rollback. |
| **Verification** | Use Lit-published verification material and public attestation paths. | You publish or provide your own attestation and provenance evidence for your users. |
| **Monitoring** | Lit handles production monitoring and incident response for the hosted service. | You own logs, metrics, alerts, capacity planning, and incident response. |
| **Billing** | Built-in Stripe credit flow. | You can keep, replace, or remove hosted-service billing assumptions depending on your deployment. |
| **Support surface** | Lit supports the hosted API and dashboard. | You own day-to-day operations; Lit can discuss support options for self-hosted environments. |
| **Customization** | Standard public API and dashboard behavior. | You can fork, patch, and integrate the stack with your own systems. |
## Governing upgrades yourself
Self-hosting is not only an operational choice — it is a **governance** choice. When
you self-host, you deploy your own `DstackApp` contract and point its owner at *your
own* Safe (or wallet, timelock, or DAO). That means **you** decide which Lit Chipotle
releases run, not Lit:
* **You approve every release.** Lit publishing a new version does not change what
your enclave runs. A new compose hash only takes effect in your deployment when
your signers whitelist it on-chain.
* **On your own timeline.** Pin a reviewed compose hash indefinitely, audit a new Lit
release at your own pace, and whitelist it only when you are satisfied. There is no
forced upgrade.
* **With your own controls.** Add a timelock for a mandatory review window, set a
higher multisig threshold, or choose your own signers. The hosted service runs a
2-of-4 Safe with no timelock; your deployment can be as conservative as your
compliance posture requires.
This is the deepest form of "don't trust, verify": you are not just verifying Lit's
releases, you are the one authorizing them. See
[Upgrade Governance](/architecture/verification/upgrade-governance) for how the
hosted service does this and what signers verify before approving.
## When self-hosting makes sense
Consider self-hosting when:
* Your security model requires operating your own TEE deployment.
* Your users or auditors need verification evidence produced by your organization.
* You need custom networking, domains, data retention, monitoring, or compliance controls.
* You want to fork the dashboard, API surface, billing flow, or deployment automation.
* You need a private environment for internal workloads, regulated customers, or dedicated capacity.
The hosted service is usually the better default when you want to ship quickly, avoid infrastructure work, and use Lit's standard operational path.
## Operational checklist
A production self-hosted deployment usually needs:
* A TEE environment running dstack — any platform from [Where you can deploy](#where-you-can-deploy).
* A reproducible Docker build and release pipeline.
* Chain configuration and deployed permission contracts.
* RPC endpoints for the chains your actions and management flows depend on.
* Persistent configuration for API, billing, and account metadata where applicable.
* Monitoring for API health, action execution, chain RPC failures, billing failures, and TEE attestation endpoints.
* A documented upgrade and rollback process.
* A verification process your users can run or inspect.
## References
Production deployment notes for the API server and Lit Actions runtime on Phala Cloud.
How users can verify that a TEE deployment is running the expected code.
Step-by-step attestation, image provenance, and code verification flow.
Run the full stack locally with the dstack simulator and Anvil.
## Contact
For self-hosting discussions, email [support@litprotocol.com](mailto:support@litprotocol.com?subject=Self-hosting%20Lit%20Chipotle). Include the environment you want to run, whether you need production support, expected request volume, security/compliance requirements, and any customization you expect to maintain.
# What Is Attestation?
Source: https://docs.dev.litprotocol.com/architecture/verification/attestation
A plain-English explanation of remote attestation: what a TEE proves, what an attestation quote is, and how a stranger turns 'I received a quote' into 'I can trust this server' — without trusting Lit or the cloud provider.
Every other page in this section tells you *how* to verify Lit Chipotle. This page
explains *what you are actually verifying* and *why it is trustworthy* — in plain
language, before any commands. If you have never worked with a Trusted Execution
Environment (TEE) before, start here.
## The problem attestation solves
When you send data to an ordinary cloud server, you are trusting a lot of people you
will never meet: the application operator (Lit), the cloud provider (Phala/Intel),
the OS administrators, anyone with physical access to the machine. Any of them
*could*, in principle, read your data, swap the code for a malicious version, or
copy out a private key. You have no way to tell. "Trust us" is the only guarantee.
**Remote attestation removes that trust.** It lets a remote machine produce
hardware-signed, mathematical proof of *exactly what code it is running* — proof you
can check yourself, that no operator (including Lit) can forge. Instead of "trust the
operator," the guarantee becomes "trust the silicon and the math."
This is the idea behind the broader [Proof of Cloud](https://proofofcloud.org/)
movement: cloud services that prove what they run rather than asking you to take
their word for it.
## What a TEE is
A **Trusted Execution Environment** is a hardware-isolated region of a CPU. Lit
Chipotle runs inside **Intel TDX** (Trust Domain Extensions), a TEE built into recent
Intel server chips. Inside the TEE:
* Memory is **encrypted by the CPU**. Even someone with root on the host, physical
access to the RAM, or a hypervisor cannot read the plaintext.
* The boot chain is **measured**. As firmware, kernel, and application load, the CPU
records a cryptographic hash of each stage into tamper-proof registers.
* Secrets generated inside (like the TLS private key) **never leave**. There is no
API to extract them, by design.
A TEE is not magic — it rests on the assumption that Intel's hardware root of trust
is sound, and TEEs have known classes of side-channel research. But it replaces *many
fully-trusted humans* with *one well-studied hardware assumption you can reason
about*.
## What an attestation quote is
When the TEE boots, it produces an **attestation quote**: a small binary document,
**signed by Intel's hardware root of trust**, that contains the measurements of
everything that loaded. Think of it as a notarized receipt the silicon hands you,
saying "here is exactly what I am running."
The measurements live in registers called **MRTD** and **RTMR0–RTMR3**:
| Register | What it records |
| --------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **MRTD** | The virtual firmware |
| **RTMR0–RTMR2** | Hardware config, kernel, boot parameters — i.e. the **operating system** |
| **RTMR3** | The **application**: the hash of the docker-compose configuration (the *compose hash*), plus which KMS issued its keys |
Two numbers carry most of the weight:
* The **OS measurements** (MRTD/RTMR0–2) prove which operating system image booted.
* The **compose hash** (in RTMR3) proves which application code booted — it is the
SHA-256 of the exact, image-digest-pinned docker-compose that defines Lit Chipotle.
If even one byte of the OS or the application changes, these numbers change, and the
quote no longer matches what is expected.
## How a stranger turns a quote into trust
Receiving a quote is not enough — anyone can hand you a document. The value comes
from four independent checks, each of which you can run yourself:
1. **Is the quote real?** Verify Intel's signature on the quote against Intel's
published root certificates. This proves the quote came from genuine TDX hardware
running with security patches up to date (not a simulator, not debug mode).
2. **Is the OS one I trust?** Check the OS measurements against a known-good dstack OS
release — and confirm that release is whitelisted on-chain (see below).
3. **Is the code one I trust?** Recompute the compose hash from the public source and
confirm it matches the quote — and that it is whitelisted on-chain.
4. **Am I really talking to that TEE?** The TLS certificate for
`api.chipotle.litprotocol.com` was generated *inside* the TEE and bound into the
quote, so a successful HTTPS handshake proves your connection terminates in the
attested enclave — no proxy in the middle. (See
[Zero-Trust TLS](/architecture/verification/index#zero-trust-tls).)
The crucial move is in steps 2 and 3: "a version I trust" is not decided by Lit. It
is decided by a **whitelist held in smart contracts on Base**, governed by a Safe
multisig. Lit cannot ship code to the production enclave without first getting that
code's compose hash approved on-chain, in public, by multiple signers. That is what
the [On-Chain KMS](/architecture/verification/onchain-kms) page covers, and the
approval process itself is documented in
[Upgrade Governance](/architecture/verification/upgrade-governance).
## Why this is stronger than "trust the operator"
Put the pieces together and the trust model inverts:
* The **cloud provider** cannot read your data — memory is CPU-encrypted.
* **Lit** cannot silently change the code — a new version cannot get keys until its
compose hash is whitelisted on Base by a multisig, visible to everyone.
* **No single Lit employee** can change that whitelist — it takes a multisig quorum.
* **You** do not have to trust any of the above — you re-derive every measurement and
read the on-chain whitelist yourself.
That is the whole point of attestation: *don't trust, verify.*
## What's next
The Phala Trust Center one-click report, plus three commands you can paste into a terminal.
How Base smart contracts — not Lit or Phala — gate key release to the enclave.
How a new release is reviewed and approved by the Safe multisig before it can run.
Replay the RTMR3 event log and check every layer yourself, end to end.
## Further reading
* [Proof of Cloud](https://proofofcloud.org/) — the verifiable-cloud movement
* [Intel TDX overview](https://www.intel.com/content/www/us/en/developer/tools/trust-domain-extensions/overview.html)
* [Phala: Attestation Overview](https://docs.phala.com/phala-cloud/attestation/overview)
* [Phala: Complete Chain of Trust](https://docs.phala.com/phala-cloud/attestation/chain-of-trust)
# Chain of Trust Reference
Source: https://docs.dev.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://docs.dev.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://docs.dev.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://docs.dev.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://docs.dev.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
# Upgrade Governance
Source: https://docs.dev.litprotocol.com/architecture/verification/upgrade-governance
How a new Lit Chipotle release is approved before it can run: the two independent actions every upgrade requires, the Safe multisig that gates it, what signers verify before approving, and how a self-hoster governs releases on their own terms.
Attestation proves *what code an enclave is running*. This page covers the other
half: *who decides which code is allowed to run*, and how that decision is made in
the open so you can audit it.
The short version: **no Lit employee — and no single party at all — can push code to
the production enclave unilaterally.** Every upgrade requires both an on-chain
governance approval by a multisig and a separate deployment, and neither alone is
enough.
## Two independent actions, by design
Because the [On-Chain KMS](/architecture/verification/onchain-kms) only releases keys
to enclaves whose compose hash is whitelisted, shipping a new version takes **two
actions that cannot be performed by the same step**:
1. **Governance** — the Safe multisig approves an on-chain transaction adding the new
release's compose hash to the `DstackApp` whitelist.
2. **Deployment** — CI builds and pushes the new image and restarts the CVM.
If you deploy without the whitelist, the new enclave boots but the KMS refuses its
keys, so it cannot serve traffic. If you whitelist without deploying, nothing
changes. Both are public; both are required.
## Who holds the keys
The `DstackApp` contract ([`0x3F91…05FfC`](https://basescan.org/address/0x3F91Deaf16FF7C823eE65081d6bAFA1cEea05FfC))
and the Phala KMS contract are both owned by a **Safe multisig**
([`0xF688…1098`](https://app.safe.global/base:0xF688411c0FFc300cAb33EB1dA651DBb3E6891098))
on Base.
| Property | Value | How to confirm |
| ---------------- | ------------------------------------------- | ------------------------------------------------------------------------------------ |
| **Threshold** | **2 of 4** signers must approve any change | `cast call 0xF688…1098 "getThreshold()(uint256)" --rpc-url https://mainnet.base.org` |
| **Signers** | 4 distinct Lit-controlled keys | `cast call 0xF688…1098 "getOwners()(address[])" --rpc-url https://mainnet.base.org` |
| **Safe version** | 1.4.1 | `cast call 0xF688…1098 "VERSION()(string)" --rpc-url https://mainnet.base.org` |
| **Timelock** | None — the Safe owns the contracts directly | `owner()` on the app returns the Safe address, not a timelock |
A single compromised signer key cannot change the whitelist; it takes a quorum.
There is currently **no timelock delay** on top of the multisig, so an approved
change takes effect as soon as the quorum executes it. (If you require a mandatory
review window before any code can receive keys, that is one reason to self-host — see
below.)
These values are live on-chain. Always confirm them yourself with the `cast` commands
above rather than trusting this page — that is the whole point of attestation.
## The release flow
```mermaid theme={null}
flowchart TB
subgraph Build["Build (GitHub Actions)"]
A["Merge to main\n(reviewed PR)"] --> B["Build image,\npush with @sha256 digest"]
B --> C["Sigstore cosign signs image\n(keyless, GitHub OIDC → Rekor)"]
C --> D["Compute compose hash\nfrom digest-pinned compose"]
end
subgraph Gov["Governance (Safe on Base)"]
E["Propose: addComposeHash(newHash)"] --> F{"2-of-4\nsigners approve?"}
F -->|yes| G["Execute → hash whitelisted"]
F -->|no| X["Upgrade blocked"]
end
subgraph Deploy["Deploy"]
H["phala deploy\n(new digest in compose)"] --> I["New CVM boots,\nrequests keys from KMS"]
I --> J{"compose hash\nwhitelisted?"}
J -->|yes| K["Keys issued,\nserves traffic"]
J -->|no| N["Keys denied,\ncannot serve"]
end
D --> E
D --> H
G -.->|"must be confirmed\nbefore boot"| J
style X fill:#fee2e2,stroke:#dc2626
style N fill:#fee2e2,stroke:#dc2626
style K fill:#dcfce7,stroke:#16a34a
```
Production uses a **two-phase Safe workflow**: a CI step *proposes* the
`addComposeHash` transaction, signers *approve* it in the Safe UI, and it is then
*executed* on-chain. The ordering requirement is strict — the whitelist transaction
must be confirmed **before** the new CVM boots and requests keys, or the KMS rejects
it.
## What signers verify before approving
The multisig is only as strong as what its signers check before they sign. A
compose hash is just 32 bytes; approving it blindly would defeat the model. Before
approving an `addComposeHash` transaction, a signer confirms:
1. **Provenance** — the hash corresponds to an image built by GitHub Actions from a
reviewed, merged commit on `LIT-Protocol/chipotle`, verifiable via its Sigstore
cosign signature in the public [Rekor](https://docs.sigstore.dev/) transparency
log (see [Chain of Trust → Image provenance](/architecture/verification/chain-of-trust#application-layer)).
2. **Reproducibility** — the compose hash can be recomputed locally from the
digest-pinned `docker-compose` and matches the value in the proposed transaction.
3. **Diff review** — the change between the currently-whitelisted release and the new
one has been reviewed.
Only after these hold does a signer add their approval. Two independent signers
performing this check is the human gate behind the cryptographic one.
## Rollback
To roll back, redeploy a previous image whose compose hash is **still whitelisted** —
no governance action is required, because old hashes are not removed automatically.
To *forbid* a version (e.g. one found to be vulnerable), the multisig removes its
compose hash from `DstackApp` with `removeComposeHash`, another 2-of-4 action. See
[Incident Response](https://github.com/LIT-Protocol/chipotle/blob/main/architectureDocs/deployment/incident-response.md)
for compromise and emergency-revocation scenarios.
## Self-hosting: govern releases on your own terms
If you self-host Lit Chipotle, **you own the governance, not Lit.** You deploy your
own `DstackApp` contract and point its owner at *your own* Safe (or wallet, timelock,
or DAO). That means:
* **You approve every release.** Lit publishing a new version does not change what
*your* enclave runs. A new compose hash only takes effect in your deployment when
*your* signers whitelist it.
* **On your own timeline.** You can pin a reviewed compose hash indefinitely, audit a
new Lit release at your own pace, and whitelist it only when you are satisfied —
there is no forced upgrade.
* **With your own controls.** Want a mandatory review delay? Put a timelock in front
of your Safe. Want a higher quorum or different signers? Configure your own
threshold. The hosted service runs a 2-of-4 Safe with no timelock; your deployment
can be as conservative as your compliance posture requires.
This is the deepest form of the "don't trust, verify" model: you are not just
verifying Lit's releases, you are the one *authorizing* them. See
[Self-Hosting](/architecture/self-hosting) for the operational picture.
## What's next
The contracts that gate key release, and how to confirm the multisig owns them.
The plain-English foundation: what the enclave proves and why it's trustworthy.
Run and govern your own deployment, approving releases on your own timeline.
Walk the on-chain governance Safe and verify every layer yourself.
# Lit Protocol
Source: https://docs.dev.litprotocol.com/index
One programmable runtime for everything between an event and a signed action. Read from any source, compute inside a chain-secured TEE, write to any chain or API — with no backend to trust.
Lit is a programmable runtime that reads data from any source, runs your JavaScript inside a chain-secured TEE, and signs on any chain or API. Keys never leave the enclave, and the code that's allowed to use them is governed on-chain. You get the speed and expressiveness of a single trusted runtime, with the auditability of a smart contract.
Create an account, fund it, and run your first Lit Action in a few minutes — via the Dashboard or the REST API.
## Build
The fastest paths to a running integration.
Web GUI for accounts, API keys, wallets (PKPs), IPFS actions, and groups.
Drive the same workflows from cURL, the lightweight JS SDK, or your own client built from the OpenAPI spec.
JavaScript that runs inside the network's TEE — read, decide, sign, in one file.
## Use cases
Patterns you can build on one programmable runtime.
Read state on one chain, sign on another — bridges, mirrors, and replays without a multisig in the middle.
Aggregate any HTTP or RPC feed inside the TEE, sign the result with a PKP, deliver it anywhere a signature is trusted.
Sign only when on- or off-chain conditions hold — sanctions screens, price thresholds, KYC checks, dispute windows.
Encrypt API keys, credentials, or user data under a PKP — decryptable only by an action you've authorized on-chain.
## Concepts
How the runtime works and how trust is established.
Your keys' authority lives on-chain; an attested TEE enforces it by reading the chain.
The three layers: chain-secured TEE, on-chain permissions, and IPFS-hosted actions.
How API keys, scopes, and account ownership combine to authorize requests.
Bind Programmable Key Pairs to permitted action CIDs and usage keys.
Attest that the enclave is running the code it claims to be running.
## Operate
Account ownership, billing, and key management.
API mode vs. ChainSecured mode — picking an ownership model and migrating between them.
Account keys vs. usage keys, and how to scope them.
Credit-based billing, how requests are metered, and how to add funds.
Open-source repos, deployment ownership, and the tradeoffs of operating your own Lit stack.
## Reference
Functions available inside an action: signing, encryption, HTTP, response.
Full REST API schema — generate clients in any language.
# Lit Actions SDK
Source: https://docs.dev.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://docs.dev.litprotocol.com/lit-actions/examples
Common Lit Action patterns covering signing, encryption, decryption, HTTP fetching, contract calls, sending ETH, gating ERC-20 transfers on sanctions data, multi-source price oracles, AI-consensus prediction-market resolution, permissionless cross-chain token bridging, non-custodial threshold-ECDSA co-signing, and keyless Solana (ed25519) transaction signing.
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`.
For examples that need more than one file to run — a Solidity contract, a deploy script, an off-chain client — see the [`examples/` folder in the repo](https://github.com/LIT-Protocol/chipotle/tree/main/examples).
***
## 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 from an action that is permitted to use the same PKP.
***
## 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`.
***
## 8. Gate an ERC-20 Transfer on On-Chain Sanctions Data (Cross-Chain)
Screen the recipient of every transfer against the [Chainalysis on-chain sanctions oracle](https://go.chainalysis.com/chainalysis-oracle-docs.html) and only sign a transfer authorization when the recipient is clear.
The Chainalysis oracle is free and keyless — it's just a smart contract at `0x40C57923924B5c5c5455c48D93317139ADDaC8fb` you can `staticcall`. But it is only deployed on a handful of mainnets (Ethereum, Arbitrum, Polygon, BSC, Avalanche, Optimism, Celo). On **Base, Linea, Scroll, any L3, any testnet, or any non-EVM chain**, a contract can't reach it. The Lit Action bridges that gap: it `eth_call`s the oracle on Ethereum mainnet, then signs an authorization that the `CompliantToken` contract — deployed wherever you want — verifies with `ecrecover`.
The signature uses `Lit.Actions.getLitActionPrivateKey()` — an identity derived from the action's IPFS CID. See [Action-Identity Signing](./patterns#action-identity-signing--immutable-proofs).
The trust anchor is a hardcoded hostname whitelist. Anyone calling the action supplies `screeningRpcUrl` via `js_params`, so a caller-supplied `chainId` check would just be theater (pair a malicious RPC with a matching chain id, gate passes). Instead the action checks the URL's hostname against `eth-mainnet.g.alchemy.com` — TLS guarantees we're actually talking to Alchemy. Trust shifts to "Alchemy is honest about Ethereum mainnet." See [Hostname-Pinned RPC Trust Anchors](./patterns#hostname-pinned-rpc-trust-anchors).
```javascript theme={null}
// js_params: {
// from, to, amount, nonce, deadline, contractAddress, chainId,
// screeningRpcUrl // must be an https://eth-mainnet.g.alchemy.com URL
// }
const CHAINALYSIS_ORACLE = "0x40C57923924B5c5c5455c48D93317139ADDaC8fb";
const IS_SANCTIONED_SELECTOR = "0xdf592f7d"; // keccak256("isSanctioned(address)")[0..4]
const ALLOWED_SCREENING_HOST = /^eth-mainnet\.g\.alchemy\.com$/i;
async function main({
from, to, amount, nonce, deadline, contractAddress, chainId, screeningRpcUrl,
}) {
const host = new URL(screeningRpcUrl).hostname;
if (!ALLOWED_SCREENING_HOST.test(host)) {
return { authorized: false, reason: `host not whitelisted: ${host}` };
}
const callData = IS_SANCTIONED_SELECTOR +
to.toLowerCase().replace(/^0x/, "").padStart(64, "0");
const result = await rpc(screeningRpcUrl, "eth_call", [
{ to: CHAINALYSIS_ORACLE, data: callData }, "latest",
]);
if (!result || result === "0x") {
return { authorized: false, reason: "oracle returned empty data" };
}
if (BigInt(result) !== 0n) {
return { authorized: false, reason: "Recipient is sanctioned" };
}
const digest = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["address", "address", "uint256", "bytes32", "uint256", "address", "uint256"],
[from, to, amount, nonce, deadline, contractAddress, chainId]
)
);
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
const signature = await wallet.signMessage(ethers.utils.arrayify(digest));
return { authorized: true, signature };
}
async function rpc(url, method, params) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
});
const body = await res.json();
if (body.error) throw new Error(body.error.message);
return body.result;
}
```
The contract pins the action's derived address at deploy time — derive it once by calling `Lit.Actions.getLitActionWalletAddress({ ipfsId })` from inside any helper action, then pass that address to the `CompliantToken` constructor.
Swapping providers (Infura, QuickNode, your own node) means editing the regex — which produces a new action CID and signer address, requiring a redeploy. That's by design: the trust anchor is content-addressed. For richer screening — hacker wallets, mixer interactions, fresh threat intel — swap the on-chain lookup for a paid API like Chainalysis KYT, TRM Labs, or GetBlock. The pattern becomes: encrypt the API key to a PKP, decrypt inside the TEE, call the API, sign on pass.
The matching contract signs nothing itself — it just verifies that the digest recovers to a hard-coded PKP address:
```solidity theme={null}
function transferWithAuth(
address to, uint256 amount, bytes32 nonce, uint256 deadline, bytes calldata signature
) external returns (bool) {
if (block.timestamp > deadline) revert AuthorizationExpired();
if (usedNonces[msg.sender][nonce]) revert NonceAlreadyUsed();
bytes32 digest = keccak256(abi.encode(
msg.sender, to, amount, nonce, deadline, address(this), block.chainid
)).toEthSignedMessageHash();
if (digest.recover(signature) != complianceOracle) {
revert InvalidComplianceSignature();
}
usedNonces[msg.sender][nonce] = true;
_transfer(msg.sender, to, amount);
return true;
}
```
The plain `transfer` and `transferFrom` overrides revert, so every movement of tokens must go through this gate.
The full runnable example — token contract, hardhat deploy script, and an end-to-end transfer runner — lives at [`examples/compliance-transfer-gate/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/compliance-transfer-gate) in the repo. The example is keyless: the action reads the Chainalysis oracle via an Alchemy RPC and signs with its own CID-derived key, so no PKP or encrypted secrets are required.
***
## 9. Median Price Oracle Across Three Exchanges
Fetch a spot price from three independent exchanges (Coinbase, Kraken, Bitstamp), take the median, and sign it for any EVM chain. This is the practical "I need a Chainlink-shaped feed without Chainlink" pattern.
Median (rather than strict byte-equality) is the right aggregation for live market prices — exchanges disagree by a few cents at every moment, so byte-equality would never pass. A median naturally rejects one outlier; combined with a `MAX_SPREAD_BPS` check (refuse to sign if min/max differ by more than the threshold) it catches both single-source manipulation and any-source-market-state-broken situations.
The safety thresholds (`MAX_SPREAD_BPS`, `MIN_SOURCES`, `DECIMALS`) are hardcoded constants in the action source rather than caller-supplied `js_params`. Otherwise anyone holding the usage key could request a signature with `MIN_SOURCES: 1` and a huge spread cap, bypassing the median-of-three story. Editing a constant mints a new action CID — and therefore a new signer address — which forces a redeploy of the registry. The trust anchor is content-addressed.
All three sources here are keyless public HTTP endpoints — no API keys, no PKP, no encryption.
```javascript theme={null}
// js_params: { asset, registryAddress, registryChainId, deadline }
const MAX_SPREAD_BPS = 100; // 1%
const MIN_SOURCES = 2; // require >= this many successful fetches
const DECIMALS = 8; // fixed-point precision for the signed price
const SYMBOLS = {
ETH: { coinbase: "ETH-USD", kraken: "ETHUSD", krakenKey: "XETHZUSD", bitstamp: "ethusd" },
BTC: { coinbase: "BTC-USD", kraken: "XBTUSD", krakenKey: "XXBTZUSD", bitstamp: "btcusd" },
};
async function main({ asset, registryAddress, registryChainId, deadline }) {
const s = SYMBOLS[asset];
if (!s) return { authorized: false, reason: `unsupported asset: ${asset}` };
const settled = await Promise.allSettled([
fetch(`https://api.coinbase.com/v2/prices/${s.coinbase}/spot`)
.then((r) => r.json()).then((b) => ({ name: "coinbase", price: Number(b.data.amount) })),
fetch(`https://api.kraken.com/0/public/Ticker?pair=${s.kraken}`)
.then((r) => r.json()).then((b) => ({
name: "kraken",
price: Number((b.result[s.krakenKey] || Object.values(b.result)[0]).c[0]),
})),
fetch(`https://www.bitstamp.net/api/v2/ticker/${s.bitstamp}/`)
.then((r) => r.json()).then((b) => ({ name: "bitstamp", price: Number(b.last) })),
]);
const ok = settled
.filter((r) => r.status === "fulfilled" && r.value.price > 0)
.map((r) => r.value);
if (ok.length < MIN_SOURCES) {
return { authorized: false, reason: `only ${ok.length}/3 sources succeeded` };
}
const prices = ok.map((s) => s.price).sort((a, b) => a - b);
const median = prices.length % 2
? prices[(prices.length - 1) / 2]
: (prices[prices.length / 2 - 1] + prices[prices.length / 2]) / 2;
const spreadBps = Math.round(((prices[prices.length - 1] - prices[0]) / median) * 10000);
if (spreadBps > MAX_SPREAD_BPS) {
return { authorized: false, reason: `spread ${spreadBps} bps exceeds ${MAX_SPREAD_BPS}` };
}
// Use string-concat + BigInt instead of Math.round(median * 10**DECIMALS)
// so we don't lose precision (or overflow Number.MAX_SAFE_INTEGER) at
// DECIMALS=18.
const priceInt = scaleToFixedPoint(median, DECIMALS);
const observedAt = Math.floor(Date.now() / 1000);
const digest = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["string", "uint256", "uint8", "uint256", "uint256", "address", "uint256"],
[asset, priceInt, DECIMALS, observedAt, deadline, registryAddress, registryChainId]
)
);
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
const signature = await wallet.signMessage(ethers.utils.arrayify(digest));
return {
authorized: true,
signature,
asset,
price: priceInt.toString(),
decimals,
observedAt,
spreadBps,
sources: ok,
};
}
```
To move the median an attacker needs to influence two of three sources at the same instant — for major exchanges that is enormously expensive — and the spread check fails closed if any pair of sources gives implausibly different prices.
The full runnable example — `PriceOracle` registry contract, deploy script, end-to-end submission runner, and a zero-dep `npm run test-medianizer` harness that exercises the fetch logic without touching any chain — lives at [`examples/multi-source-price-oracle/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/multi-source-price-oracle) in the repo.
***
## 10. Resolve a Prediction Market by AI Consensus
Poll multiple LLM providers in parallel with the same yes/no question and only sign the resolution when every model agrees. This uses the [Multi-Source Consensus](./patterns#multi-source-consensus) pattern with AI providers as the parallel sources. The aggregation is *strict agreement* rather than a median, because the output is categorical YES/NO/UNCLEAR.
**Perplexity Sonar is required** because its built-in web search lets it answer questions about events that happened after a frontier model's training cutoff. **OpenAI and Anthropic are optional second opinions** — independent training corpora mean a confident-but-wrong frontier answer is unlikely to be confirmed by another frontier model. Configuring all three gives you 3-of-3 agreement before anything reaches the chain.
```javascript theme={null}
// js_params: {
// questionId, questionText, resolveAt,
// marketAddress, marketChainId, deadline,
// decryptPkpId,
// encryptedPerplexityKey, // required
// encryptedOpenAiKey, encryptedAnthropicKey // optional
// }
async function main({
questionId, questionText, resolveAt,
marketAddress, marketChainId, deadline, decryptPkpId,
encryptedPerplexityKey, encryptedOpenAiKey, encryptedAnthropicKey,
}) {
if (Math.floor(Date.now() / 1000) < resolveAt) {
return { authorized: false, reason: "not yet resolvable" };
}
// Bind questionId to the prompt so a caller can't swap the text.
const computedId = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(questionText));
if (computedId.toLowerCase() !== questionId.toLowerCase()) {
return { authorized: false, reason: "questionText does not match questionId" };
}
const keys = await Promise.all([
["perplexity", encryptedPerplexityKey],
["openai", encryptedOpenAiKey],
["anthropic", encryptedAnthropicKey],
].map(async ([name, ct]) =>
ct
? { name, key: await Lit.Actions.Decrypt({ pkpId: decryptPkpId, ciphertext: ct }) }
: { name, key: null }
));
const prompt = `Prediction-market questions are phrased in future tense ` +
`but the event may have already occurred. Treat the question as ` +
`"has the predicted outcome occurred, as of now?". ` +
`Answer YES, NO, or UNCLEAR (UNCLEAR if the event hasn't happened yet ` +
`or sources disagree). Respond with a single word.\n\nQuestion: ${questionText}`;
const votes = await Promise.all(keys.map(async ({ name, key }) =>
key ? { name, vote: parseVote(await callModel(name, key, prompt)) } : null
));
const successful = votes.filter((v) => v && v.vote);
if (!successful.length) return { authorized: false, reason: "no model responded" };
if (!successful.every((v) => v.vote === successful[0].vote)) {
return { authorized: false, reason: "models disagree", votes: successful };
}
const answer = { YES: 1, NO: 2, UNCLEAR: 3 }[successful[0].vote];
const digest = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["address", "bytes32", "uint8", "uint256", "uint256"],
[marketAddress, questionId, answer, deadline, marketChainId]
)
);
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
return {
authorized: true,
signature: await wallet.signMessage(ethers.utils.arrayify(digest)),
answer,
consensusAcross: successful.map((v) => v.name),
};
}
```
Honest caveats: frontier models share training corpora, so a wrong answer that's widespread on the internet can be confidently confirmed by multiple models. Perplexity's grounding helps but isn't bulletproof — citations can drift. For real money this pattern wants a dispute window or a stake-and-slash flow on top.
The full runnable example — `PredictionMarket` contract, deploy script, key-encryption helper, propose/resolve runners, and a heavily-commented setup pipeline — lives at [`examples/prediction-market-oracle/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/prediction-market-oracle) in the repo.
***
## 11. Cross-Chain Burn/Mint Bridge
Deploy the same `BridgeToken` contract on two chains. The holder calls `burn` on chain A, which destroys the local supply and emits `BurnInitiated(from, recipient, amount, destChainId, nonce)`. A Lit Action reads that event via `eth_getTransactionReceipt` against a hostname-whitelisted RPC, validates it, and signs a mint authorization for chain B. Anyone can submit the mint — the signature is the authorization, not the caller.
The signer key comes from `Lit.Actions.getLitActionPrivateKey()`, which derives the key from the action's IPFS CID. Edit the action by a byte and the signer changes, and every deployed `BridgeToken` refuses the modified action. The trust collapses from "trust this federation of relayers" to "trust this exact piece of code." See [Action-Identity Signing](./patterns#action-identity-signing--immutable-proofs) and [Hostname-Pinned RPC Trust Anchors](./patterns#hostname-pinned-rpc-trust-anchors).
```javascript theme={null}
// js_params: {
// burnTxHash, srcChainId, srcRpcUrl, srcContract,
// destChainId, destContract, logIndex, deadline,
// }
const RPC_HOSTS = {
84532: { host: /^base-sepolia\.g\.alchemy\.com$/i, minConfirmations: 5 },
421614: { host: /^arb-sepolia\.g\.alchemy\.com$/i, minConfirmations: 5 },
};
async function main({
burnTxHash, srcChainId, srcRpcUrl, srcContract,
destChainId, destContract, logIndex, deadline,
}) {
// Hostname-whitelist the RPC per chain id, and require https://. A
// caller-supplied chainId check alone is theater (caller can lie
// consistently); the hostname + TLS scheme pin trust to "this body
// came from Alchemy's actual servers, not a path-level MITM."
const policy = RPC_HOSTS[Number(srcChainId)];
if (!policy) return { authorized: false, reason: `chainId ${srcChainId} not whitelisted` };
const parsed = new URL(srcRpcUrl);
if (parsed.protocol !== "https:") {
return { authorized: false, reason: "srcRpcUrl must use https://" };
}
if (!policy.host.test(parsed.hostname)) {
return { authorized: false, reason: `srcRpcUrl host not whitelisted` };
}
const reportedChainId = await rpc(srcRpcUrl, "eth_chainId", []);
if (BigInt(reportedChainId) !== BigInt(srcChainId)) {
return { authorized: false, reason: "RPC chainId mismatch" };
}
const receipt = await rpc(srcRpcUrl, "eth_getTransactionReceipt", [burnTxHash]);
if (!receipt || BigInt(receipt.status) !== 1n) {
return { authorized: false, reason: "burn tx missing or reverted" };
}
// Defang reorgs: don't sign until the burn is buried under N blocks.
// Otherwise a reorg can pull the burn out of history after the action
// signs, letting the user keep source tokens AND mint on the destination.
const head = BigInt(await rpc(srcRpcUrl, "eth_blockNumber", []));
if (head - BigInt(receipt.blockNumber) < BigInt(policy.minConfirmations)) {
return { authorized: false, reason: "burn not yet confirmed" };
}
const log = receipt.logs.find((l) => Number(l.logIndex) === Number(logIndex));
if (!log || log.address.toLowerCase() !== srcContract.toLowerCase()) {
return { authorized: false, reason: "log not from expected srcContract" };
}
const expectedTopic = ethers.utils.id(
"BurnInitiated(address,address,uint256,uint256,uint256)"
);
if (log.topics[0].toLowerCase() !== expectedTopic.toLowerCase()) {
return { authorized: false, reason: "not a BurnInitiated event" };
}
// BurnInitiated has indexed (from, recipient, destChainId); data carries (amount, nonce).
const recipient = ethers.utils.getAddress("0x" + log.topics[2].slice(26));
const logDestChainId = BigInt(log.topics[3]);
const [amount, srcNonce] = ethers.utils.defaultAbiCoder.decode(
["uint256", "uint256"], log.data
);
if (logDestChainId !== BigInt(destChainId)) {
return { authorized: false, reason: "burn targets a different chain" };
}
const digest = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["uint256", "address", "bytes32", "uint256", "address",
"uint256", "uint256", "uint256", "address", "uint256"],
[srcChainId, srcContract, burnTxHash, logIndex, recipient,
amount, srcNonce, deadline, destContract, destChainId]
)
);
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
return {
authorized: true,
signature: await wallet.signMessage(ethers.utils.arrayify(digest)),
srcChainId, srcContract, burnTxHash, logIndex,
recipient, amount: amount.toString(), srcNonce: srcNonce.toString(),
destChainId, destContract, deadline,
};
}
async function rpc(url, method, params) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
// Defang an open redirect on the whitelisted host that would otherwise
// let an attacker answer JSON-RPC requests after the hostname pin passed.
redirect: "error",
});
const body = await res.json();
if (body.error) throw new Error(body.error.message);
return body.result;
}
```
The destination `BridgeToken.mint` re-derives the same digest, recovers the signer, and checks it matches the pinned `bridgeOracle`. It also checks an independent `bridgePartner[srcChainId]` mapping — wired during setup to point at the sibling deployment — so a forged burn from a copycat contract with the same event shape can't mint here. Each `(srcChainId, burnTxHash, logIndex)` is recorded in `usedBurnIds` to prevent replays.
This is the permissionless half: any wallet can submit the mint tx (sponsored by a relayer, the recipient themselves, or whoever wants the gas burden). The mint goes through *only* because the signature is valid — there's no on-chain allowlist of submitters.
The full runnable example — `BridgeToken` contract, two-chain deploy script, `setBridgePartner` wiring, and an end-to-end `npm run bridge` runner that burns on one chain and mints on the other — lives at [`examples/cross-chain-token/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/cross-chain-token) in the repo. Defaults to Base Sepolia ↔ Arbitrum Sepolia; the `RPC_HOSTS` table is the only thing you'd touch to add more chains.
## 12. Policy-Gated Key Custody for a Solver / Filler
Intent-system solvers and fillers (UniswapX, Across, CoW, 1inch Fusion, ERC-7683, bridge relayers) run a bot that holds a hot key and signs fills against an inventory balance. Compromise the box, drain the inventory. This example removes the hot key: inventory lives in a `SolverVault` contract, and the only signature that releases a fill comes from a Lit Action that *is* the policy. The bot can *ask* Lit to authorize a fill; it can't authorize one itself.
The action reads the real order/deposit from a **pinned, trusted** settlement contract on-chain and reconstructs the fill from it — so the recipient and amount come from what the order actually says, not from anything the (possibly compromised) caller supplies. Only then does it sign, using its CID-derived identity (see [Action-Identity Signing](./patterns#action-identity-signing--immutable-proofs)). Edit the policy and the signer address changes, so the vault stops trusting the modified action.
```javascript theme={null}
// Abridged core — the action pins the settlement source, binds the fill to the
// on-chain order, enforces policy, then signs. Full multi-file example in the repo.
async function main({ vaultAddress, chainId, token, recipient, amount, nonce, deadline, settlementContract, depositId, rpcUrl }) {
// 1. Trust anchors: whitelist the RPC host AND only read a pinned/allowlisted
// settlement — otherwise a compromised caller points us at a contract that
// emits a forged order and we'd sign a fill paying the attacker.
// 2. Bind to the real order: read it on-chain and require the requested
// recipient/token/amount to match what the order actually says.
const order = await readOrder(rpcUrl, settlementContract, depositId);
if (getAddress(recipient) !== getAddress(order.recipient)) {
return { authorized: false, reason: "recipient does not match the on-chain order" };
}
// 3. Enforce policy (notional cap, kill switch, allowlist), then sign the
// exact tuple the vault's executeFill verifies.
const digest = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
["address", "address", "uint256", "bytes32", "uint256", "address", "uint256"],
[token, recipient, amount, nonce, deadline, vaultAddress, chainId]
)
);
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
return { authorized: true, signature: await wallet.signMessage(ethers.utils.arrayify(digest)) };
}
```
The full runnable example — `SolverVault` / `AcrossSolverVault` contracts, the policy actions, attacker scripts that prove exfiltration is impossible, an `exit()` cold-wallet path, and a **live Across testnet relayer** (deposit on Sepolia → Lit authorizes → vault fills on Base Sepolia, \~355 ms round-trip), plus a read-only ops dashboard — lives at [`examples/lit-solver-vault/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/lit-solver-vault) in the repo.
## 13. Compliant Private Stablecoin
Every public stablecoin (USDC, USDT, PYUSD) puts your whole financial life on a public ledger: payroll, vendor payments, who paid whom and how much, forever. Shielded pools (Zcash, Aztec) fix the privacy but have no compliance story, so issuers won't touch them. This example is the missing middle — private by default, compliant by construction — and it gets there without a ZK circuit.
Balances aren't a public mapping; a wallet's balance is a set of **notes** (`{owner, amount, salt}`). On-chain you only ever see a note's **commitment** (`keccak256(owner, amount, salt)`), its **nullifier** when spent, and its contents **encrypted** to a ledger PKP (decryptable only inside an authorized Lit Action). A private transfer publishes new commitments + a nullifier + ciphertext — no amount, no parties. The Lit Action plays the role a ZK circuit plays in Zcash/Aztec: it reads chain state over a **pinned** RPC, validates the input notes exist and are unspent, checks `sum(inputs) == sum(outputs)`, runs OFAC screening (the [sanctions gate](#8-gate-an-erc-20-transfer-on-on-chain-sanctions-data-cross-chain) from §8), then signs the state update with its CID-derived identity (see [Action-Identity Signing](./patterns#action-identity-signing--immutable-proofs)). KYC runs only at the dollar edges (mint/redeem), reserves are publicly provable (`usdc.balanceOf(vault) ≥ totalSupply()`), and a regulator holding a threshold-signed warrant can decrypt exactly one note while every other balance stays dark.
```javascript theme={null}
// Abridged core of the shieldedTransfer op — the action is the prover.
// Full multi-file example (mint / transfer / redeem / disclose) in the repo.
async function transfer({ inputs, outputs, caller, contractAddress, contractRpcUrl, screeningRpcUrl, nonce, deadline, chainId }) {
// 1. Validate against REAL chain state over a pinned (https, whitelisted-host)
// RPC — a caller-supplied RPC could forge "this note exists" and mint value.
const live = await checkInputsLive(inputs, contractAddress, contractRpcUrl);
if (!live.ok) return live;
if (sum(inputs) !== sum(outputs)) return { ok: false, reason: "value not conserved" };
// 2. OFAC-screen every recipient; 3. encrypt each output note to the ledger PKP.
const ofac = await screenAll(outputs.map((o) => o.owner), screeningRpcUrl);
if (!ofac.ok) return ofac;
const { commitments, encryptedBlobs } = await buildNotes(outputs); // Lit.Actions.Encrypt
// 4. Sign the exact tuple PrivUSD.shieldedTransfer verifies (ecrecover == CID signer).
const digest = ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(
["string", "bytes32[]", "bytes32[]", "string[]", "bytes32", "uint256", "address", "uint256"],
["TRANSFER", inputs.map(nullifierOf), commitments, encryptedBlobs, nonce, deadline, contractAddress, chainId]
));
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
return { ok: true, signature: await wallet.signMessage(ethers.utils.arrayify(digest)), inputNullifiers: inputs.map(nullifierOf), outputCommitments: commitments, encryptedBlobs };
}
```
The full runnable example — the `PrivUSD` contract (commitments, nullifiers, encrypted blobs, reserve proof), the ledger action (mint/transfer/redeem prover with OFAC + KYC baked in), a warrant-gated `disclose` action, a 2-minute scripted demo, and a Hardhat test suite — lives at [`examples/private-stablecoin/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/private-stablecoin) in the repo. It builds on the [sanctions gate](#8-gate-an-erc-20-transfer-on-on-chain-sanctions-data-cross-chain) (§8) and runs live on Base Sepolia.
## 14. A Unique, Immutable Wallet Per User (Bound to the Action)
A common request: *"bind a wallet to an action immutably, and give each user their own."* You can do this with a ChainSecured account and a contract that mints and binds PKPs to a group — but there's a lighter pattern that gets the same "only this exact code can sign" property with no PKP and no contracts.
Every action has a key derived from its IPFS CID via [`getLitActionPrivateKey()`](./patterns#action-identity-signing--immutable-proofs) — so the wallet is *bound to the code*. To get a different wallet per user, make the code different per user: **hardcode the user's address into the action**. That one line is part of what the CID hashes, so each user gets a different CID and therefore a different, immutable wallet. Authorize spending by recovering a signature inside the action and comparing it to the hardcoded owner — the usage key that runs the action grants no spending power.
```javascript theme={null}
// OWNER_ADDRESS is stamped in per user. Two users => two CIDs => two wallets.
const OWNER_ADDRESS = "0xUSERS_ADDRESS";
async function main({ action, token, to, amount, nonce, deadline, signature, chainId, rpcUrl }) {
// The wallet's key is derived from THIS action's CID (unique per user).
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
if (action === "address") return { walletAddress: wallet.address }; // no auth: where to deposit
// Withdraw: only if the owner signed THIS exact transfer. The nonce must equal
// the wallet's current on-chain nonce, so a used authorization can't replay.
const message = [ "withdraw", wallet.address, chainId, token, to, amount, nonce, deadline ].join(":");
if (ethers.utils.verifyMessage(message, signature).toLowerCase() !== OWNER_ADDRESS.toLowerCase()) {
return { ok: false, reason: "signer is not the bound owner" };
}
const data = new ethers.utils.Interface(["function transfer(address,uint256)"])
.encodeFunctionData("transfer", [to, amount]);
const rawTx = await wallet.signTransaction({ to: token, data, nonce, gasLimit: 100000, chainId });
return { ok: true, rawTx }; // caller broadcasts
}
```
Each user's action has a different CID and therefore a different wallet, so there's no code path from one user's action to another's balance — and the usage key can *run* any action but can only relay a withdrawal the real owner already signed.
The full runnable example — the per-user action template, a `DemoToken` ERC-20, a one-shot setup, deposit/balance/withdraw scripts, and a wrong-user attack that the action refuses — lives at [`examples/action-bound-wallet/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/action-bound-wallet) in the repo.
## 15. Confidential Dark Pool (Encrypted Orders, Matched in the Enclave)
A dark pool is a venue where orders stay hidden until they match, so large orders can't be front-run. Every other example on this page is *compute + sign*; this one is the first to use **encryption** and to hold **confidential state**, which is what a dark pool actually needs. Traders submit orders **encrypted** to a vault PKP; they're stored as ciphertext in ordinary Postgres (so the database operator only ever sees gibberish — even the DB connection string is an encrypted secret the action decrypts at runtime); at the end of each epoch a Lit Action decrypts the whole batch **inside the TEE**, runs a single-clearing-price sealed-bid auction, and signs the resulting fills for an on-chain settlement contract that pins the action's CID-derived address.
It's a sealed-bid **batch auction**, not a continuous order book, on purpose: a uniform clearing price removes time-priority, so there's no ordering advantage to front-run, and the whole batch matches in one atomic enclave run (no per-call sequencing on top of stateless actions). Each order is **signed by its trader** and the matcher verifies that signature in-enclave, so nobody — not even the operator holding the usage key — can forge an order against someone else's escrow.
Privacy here is enforced by the TEE: orders are decrypted only inside an attested enclave and are never exposed to the operator, the database, or other traders — the book is matched in hardware isolation, with true async batching. The privacy is **pre-trade** — orders are hidden until they match; settled fills are public on-chain, like a real dark pool's trade reporting. See the example's "Security model & limitations" for the full trust picture, including what trusting the enclave entails.
```javascript theme={null}
// Abridged core of the epoch matcher. Full multi-file example in the repo.
// js_params: { pkpId, encryptedDbUrl, epoch, pair, settlement, chainId, maxBatch }
async function main({ pkpId, encryptedDbUrl, epoch, pair, settlement, chainId }) {
// 1. Decrypt the DB credential, then pull the epoch's ciphertext orders over HTTP.
const dbUrl = await Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedDbUrl });
const rows = await sqlOverHttp(dbUrl, "select id, ciphertext from orders where epoch=$1 and pair=$2 and not settled", [epoch, pair]);
// 2. Decrypt each order INSIDE the enclave and keep only the ones the named
// trader actually signed (forged / replayed orders are dropped).
const orders = [];
for (const row of rows) {
const o = JSON.parse(await Lit.Actions.Decrypt({ pkpId, ciphertext: row.ciphertext }));
if (verifyOrderSignature(o, { chainId, settlement, epoch, pair })) orders.push(o);
}
// 3. Uniform-price sealed-bid auction → one clearing price + conserving fills,
// then sign the fills with the action's CID-derived key for on-chain settlement.
const { clearingPx, fills } = runAuction(orders);
const digest = settlementDigest(epoch, pair, clearingPx, fills, settlement, chainId);
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
return { clearingPx, fills, signature: await wallet.signMessage(ethers.utils.arrayify(digest)) };
}
```
The full runnable example — `DarkPoolSettlement` (per-epoch locked escrow + signed-fill settlement) and `TestToken` contracts, the `encryptOrder` / `matchEpoch` / `markSettled` actions, a setup that mints the vault PKP, **pins the action CIDs**, encrypts the DB connection string, and deploys the contracts, plus trader-signed `submit` and epoch `run` scripts and a `Hardhat` test suite (contract + auction + order-authentication) — lives at [`examples/dark-pool/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/dark-pool) in the repo. It uses [Neon](https://neon.com) Postgres because a Lit Action reaches the DB over HTTP, not a raw socket.
***
## 16. Non-Custodial Co-Signer: Threshold ECDSA Split Between Lit and You
Every other example on this page is a "Lit signs on your behalf" flow: the action holds a key and can sign whenever its code decides to. This one is the first where **you are a required co-signer**. The signing key is real **threshold ECDSA** (the [DKLs23](https://dkls.info/) protocol, via Silence Laboratories' Trail-of-Bits-audited WASM), split **2-of-3** by a distributed key generation: Lit holds one share, you hold a **hot** share (this machine) and a **cold** recovery share (offline). Any two shares can sign. **Lit literally cannot produce a signature without you co-signing, and the full private key is never assembled anywhere** — not even momentarily inside the action. The output is a standard secp256k1 ECDSA signature any EVM contract verifies with plain `ecrecover`.
Because you hold 2 of the 3 shares, the cold share is a self-custody escape hatch: if Lit ever disappears you sign with **hot + cold entirely client-side** (no Lit, no network), so funds never freeze. The action is stateless across calls — each round it seals its MPC session with `Lit.Actions.Encrypt({ pkpId })` and the user relays that opaque blob back the next round; neither party's share alone can sign.
```javascript theme={null}
// Abridged: one stateless MPC round of the Lit-side signing party (party 1).
// The action holds ONE share and cannot sign without the user's rounds.
// js_params: { op, round, sessionId, pkpId, encState, encKeyshare, inMsgs, messageHash, ... }
async function main({ round, sessionId, pkpId, encState, encKeyshare, inMsgs, messageHash }) {
await ensureWasm(); // DKLs23 threshold-ECDSA, run in WASM inside the node
// The node is stateless, so the action's own session/keyshare is sealed to the
// PKP and relayed back by the user each round — never reconstructed server-side.
const session = round === 1
? new SignSession(Keyshare.fromBytes(await unseal(pkpId, encKeyshare, "keyshare")), "m")
: SignSession.fromBytes(await unseal(pkpId, encState, "sign-state", round, { sessionId, messageHash }));
// Advance one round. The final round signs the digest committed in round 1 —
// and ONLY that digest, so a replayed presignature can't reuse the nonce.
const out = round < 4 ? session.handleMessages(decode(inMsgs))
: [session.lastMessage(b64ToU8(messageHash))];
// Reseal the advanced session and hand it back to the user for the next round.
return { outMsgs: encode(out), encState: await seal(pkpId, session.toBytes(), "sign-state", round + 1, { sessionId, messageHash }) };
}
```
The full runnable example — the stateless `mpcSigner` action, a user-side client + local share store, `MpcVault.sol` (verifies the MPC signature with plain `ecrecover`), `setup` / `keygen` / `deploy` / `sign` scripts covering the interactive DKG and both signing quorums (hot + Lit, and the no-Lit hot + cold recovery path) — lives at [`examples/mpc-signing-ecdsa/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/mpc-signing-ecdsa) in the repo. Verified end-to-end on Base Sepolia; `npm run keygen -- --basic` does a simpler 2-of-2 (Lit + hot, no recovery).
***
## 17. Sign a Solana Transaction (Keyless ed25519 Wallet)
Every other signing example on this page targets an EVM chain, where the action's identity key is already a secp256k1 EVM key. **Solana uses ed25519**, so this example shows the small bridge: that identity key is a 32-byte secp256k1 private key, and 32 bytes is exactly an ed25519 *seed* — which is what Solana's `Keypair.fromSeed` consumes. So you can derive a Solana keypair from the action's CID-bound identity, giving a **keyless Solana wallet that only this exact code can ever operate** — no PKP to mint, no key to hold.
The action also **inspects what it signs**: rather than blindly signing whatever bytes it's handed, it parses the serialized transaction message and signs only a single `SystemProgram` transfer whose fee payer is its own address and whose amount is under a hardcoded cap. The canonical message bytes are built client-side by `@solana/web3.js`; the parse inside the action is read-only validation, so a parser quirk can only ever *reject* — it can never sign something other than the exact bytes the client broadcasts. ed25519 + base58 come from pinned [ESM imports](./imports) (jsDelivr).
```javascript theme={null}
// Abridged. Full multi-file example (devnet client + policy parser) in the repo.
// js_params: { action: "address" | "sign", message /* base64 tx message */, recipient }
import * as ed from "@noble/ed25519@2.1.0";
import { sha512 } from "@noble/hashes@1.4.0/sha512/+esm";
import { base58 } from "@scure/base@1.1.6";
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m)); // wire sync hashing
const MAX_LAMPORTS = 500_000_000n; // 0.5 SOL — part of the source, so part of the CID
async function main({ action, message, recipient }) {
// The secp256k1 identity key (32 bytes) doubles as the ed25519 seed — the
// same 32 bytes Solana's Keypair.fromSeed uses. Bound to the CID, never
// leaves the TEE.
const seed = ed.etc.hexToBytes((await Lit.Actions.getLitActionPrivateKey()).replace(/^0x/, ""));
const publicKey = ed.getPublicKey(seed);
const address = base58.encode(publicKey);
if (action === "address") return { address };
// Parse + enforce policy (one SystemProgram transfer, fee payer == self,
// amount <= MAX_LAMPORTS). Full parser is in the repo.
const { lamports } = inspectTransfer(message, publicKey, recipient); // throws / returns reason if not allowed
if (lamports > MAX_LAMPORTS) return { authorized: false, reason: "exceeds cap" };
// Sign the EXACT message bytes the client will broadcast.
const signature = ed.sign(base64ToBytes(message), seed);
return { authorized: true, address, signature: bytesToBase64(signature) };
}
```
The full runnable example — the policy-enforcing `solanaSigner` action (with a from-scratch legacy-message parser), a one-shot setup that derives the wallet's Solana address, and `airdrop` / `transfer` client scripts that fund the wallet and round-trip a signed transfer on **devnet** (including an over-cap send the action refuses) — lives at [`examples/solana-signer/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/solana-signer) in the repo.
***
## 18. Non-Custodial Co-Signer: Threshold FROST for Solana, Bitcoin, and Zcash
The same non-custodial **2-of-3** model as [§16](#16-non-custodial-co-signer-threshold-ecdsa-split-between-lit-and-you), for the **Schnorr / EdDSA** signature family instead of ECDSA. The key is split by a real **FROST** distributed key generation — Lit's Kudelski-audited [`lit-frost`](https://github.com/LIT-Protocol/lit-frost) + [`frost-dkg`](https://github.com/mikelodder7/frost-dkg), run in WASM — so **Lit cannot sign without you, the full key never exists anywhere, and you hold 2 of 3 shares** (hot + cold) for a no-Lit recovery path. This build signs **Ed25519**, so the threshold group key *is* a **Solana** address: there is no on-chain program to deploy — you just sign a `SystemProgram.transfer` and submit it.
FROST signing is hardened against the protocol's sharpest footgun — nonce reuse. The action signs **atomically in one stateless call**: it generates its single-use nonce, signs over the full `[user, action]` commitment set, and discards the nonce. It is never sealed or relayed, so the user (the transport) has nothing to replay; a replay just gets a fresh nonce. The action also only co-signs with the **hot** share (the allowed peer set is sealed into the keyshare at keygen), pins the wasm's SHA-256, and is locked to its own CID.
### Which chains and assets
This build emits a standard **Ed25519** signature, so the threshold key is an ordinary account on any chain that verifies RFC-8032 Ed25519. Because `lit-frost` is the whole FROST *family*, switching the ciphersuite (a one-line `Scheme` change) and rebuilding the wasm reuses the exact same DKG + co-signing flow for the rest of the Schnorr/EdDSA world:
| FROST ciphersuite | Chains / assets |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Ed25519** *(this build)* | **Solana** (verified end-to-end), and other standard-Ed25519 chains — NEAR, Stellar, Aptos, Sui, TON, Cardano, Hedera, Algorand |
| **secp256k1 Taproot** (BIP-340) | **Bitcoin Taproot** (P2TR key-path spends) and other BIP-340 Schnorr signers |
| **RedDSA** (Jubjub / Pallas) | **Zcash** shielded spend authorization — Sapling (Jubjub) and Orchard (Pallas) |
| **Schnorrkel / sr25519** | **Polkadot**, **Kusama**, and other Substrate chains |
| **Ristretto255 / P-256 / P-384 / Decaf377** | Schnorr over those curves (e.g. Penumbra uses Decaf377) |
For the **ECDSA** world — every EVM chain, plus Bitcoin **legacy/SegWit**, Tron, and Cosmos secp256k1 accounts — use the [secp256k1-ECDSA sibling](#16-non-custodial-co-signer-threshold-ecdsa-split-between-lit-and-you). Between the two examples, **one share per signature-scheme family covers essentially every chain**, with no multisig contract anywhere.
Only the Ed25519 / Solana path is verified end-to-end. The other ciphersuites are supported by the audited `lit-frost` library, but each target chain still needs its own address derivation and transaction format — and Bitcoin Taproot needs the BIP-341 key tweak, Zcash shielded needs the RedDSA signature randomizer. The threshold-signature primitive is there; the chain glue is the remaining work (the same way Solana needed its transaction builder).
```javascript theme={null}
// Abridged: the Lit-side party signs ATOMICALLY in one stateless call. It holds
// ONE FROST share and cannot sign without the user's share.
// js_params: { op:"sign", pkpId, encActionKeyshare, message, peerCommitments }
async function signRound({ pkpId, encActionKeyshare, message, peerCommitments }) {
await ensureWasm(); // lit-frost FROST (Ed25519), run in WASM inside the node
// Unseal this party's share + the parameters bound at keygen: the group key,
// threshold, and the ONLY peers it may co-sign with (the hot share). The action
// trusts these, not caller-supplied values, and refuses any other quorum.
const { bytes: share, meta } = await unseal(pkpId, encActionKeyshare, "keyshare");
assertAllowedQuorum(peerCommitments, meta); // reject cold+Lit, dup/unknown/self
// BOTH FROST rounds in ONE call: a fresh nonce, sign over [user, action], then
// discard the nonce. It is never sealed or relayed, so it can never be reused
// (reusing a FROST nonce across two transcripts would leak the secret share).
const r1 = sign_round1(share);
const commits = [...peerCommitments, { id: meta.myId, data: r1.commitment }].sort(byId);
const r2 = sign_round2(message, meta.myId, share, meta.verifyingKey, meta.threshold, commits, r1.nonce);
return { commitment: r1.commitment, signatureShare: r2.signature_share, verifyingShare: r2.verifying_share };
}
```
The full runnable example — the stateless `mpcSigner` action, the user-side client + local share store, a `wasm/` wrapper that compiles `lit-frost` + `frost-dkg` to WebAssembly, and `setup` / `keygen` / `fund` / `sign` scripts covering the FROST DKG and both quorums (hot + Lit, and the no-Lit hot + cold recovery path) — lives at [`examples/mpc-signing-frost/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/mpc-signing-frost) in the repo. Verified end-to-end on Solana devnet (DKG + a hot + Lit transfer + a hot + cold recovery transfer); `npm run keygen -- --basic` does a simpler 2-of-2 (Lit + hot, no recovery).
# Module Imports
Source: https://docs.dev.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`
***
## Composability — Publish Your Own Packages
The same import mechanism that pulls in `zod` or `date-fns` works for **packages you publish yourself**. This is the recommended way to share business logic and reusable functions across many Lit Actions: extract the common code into an npm package, publish it, and import it — version-pinned — in every action that needs it.
Lit Actions are standalone programs. There is no shared filesystem between them, relative imports (`./util.js`) are rejected, and there is no project-level bundling step. Without a shared module mechanism, every action would have to inline its own copy of any helper. Publishing reusable code to npm turns that copy-paste into a real dependency:
* **One source of truth** — fix a bug or add a feature in the package, publish a new version, and bump the pinned version in each action that consumes it.
* **Smaller, readable actions** — each action expresses only its unique logic; shared validation, encoding, RPC helpers, and domain logic live in the package.
* **Auditable, immutable dependencies** — every published version is integrity-verified and pinned exactly like any third-party package.
### Requirements for your package
Your package must meet the same [compatibility requirements](#package-compatibility) as any other import:
1. **Ship ESM.** Set `"type": "module"` and point `"exports"` (or `"module"`) at an ESM build. If you write TypeScript, compile to ESM (`"module": "ESNext"`, `"target": "ESNext"`) and publish the `.js` output.
2. **No Node.js built-ins or native addons.** The code runs in the Deno/V8 sandbox — avoid `fs`, `path`, `crypto`, `http`, and the like. `ethers` (v5) and the `Lit.Actions` SDK are runtime globals; reference them as globals or accept them as arguments rather than bundling them.
3. **Keep the dependency tree small and ESM-only.** Each transitive dependency is fetched and verified too, and must itself ship ESM. Zero-dependency packages are the easiest to audit and the most reliable.
A minimal `package.json`:
```json theme={null}
{
"name": "@your-org/lit-helpers",
"version": "1.0.0",
"type": "module",
"exports": "./dist/index.js",
"files": ["dist"]
}
```
### Consuming it in an action
Once published to npm, import it like any other package — with a pinned version:
```javascript theme={null}
import { assertAllowedRpc, requireStrictAgreement } from "@your-org/lit-helpers@1.0.0";
// js_params: { pkpId, chainId, rpcUrl, message }
async function main({ pkpId, chainId, rpcUrl, message }) {
assertAllowedRpc(chainId, rpcUrl); // shared trust-anchor logic from your package
const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId }));
return { signature: await wallet.signMessage(message) };
}
```
The first time the network sees this version it is fetched from jsDelivr, double-fetched and verified (TOFU), and pinned to `integrity.lock`. Every subsequent execution — from any action — is served from cache and checked against the pinned hash.
### Versioning and action identity
Because the import specifier (including the version) is part of your action's source, the version you pin is baked into the action's IPFS CID:
* A published npm version is **immutable** — npm will not let you republish different bytes under the same version number. So a pinned import always resolves to the same code, which keeps your action's CID — and therefore its [action-derived identity key](/lit-actions/patterns#action-identity-signing-immutable-proofs) — stable.
* Bumping the version (`@1.0.0` → `@1.1.0`) changes the action source, which produces a new CID and a new action identity. Treat a dependency bump like any other change to action code: re-deploy, re-permission, and — if you rely on action-identity signing — update any verifier that pins the old address.
This is a feature: anyone verifying your action can read the import statement and see exactly which version of your logic it runs.
Imported code — including your own packages and all of their transitive dependencies — runs with the same permissions as your action, including access to `Lit.Actions.getPrivateKey()`. Audit your dependency tree and pin every version. An unpinned or compromised transitive dependency is a direct path to your keys. Prefer zero-dependency packages for logic that runs near signing.
***
## 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://docs.dev.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, and [publish your own packages](/lit-actions/imports#composability-publish-your-own-packages) to share logic across actions
* [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://docs.dev.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 combined `code` + `js_params` payload | 16 MB |
| Maximum IPFS action size | N/A |
Action code and `js_params` share a single 16 MB budget — `code` and `js_params` may each be as large as you like as long as the sum of their sizes (code bytes + JSON-serialized `js_params` bytes) does 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 | 1 MB |
| 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://docs.dev.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://docs.dev.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://docs.dev.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.
## Hostname-Pinned RPC Trust Anchors
When a Lit Action reads chain state through a caller-supplied RPC URL, do not trust a caller-supplied `chainId` by itself. A malicious caller can point the action at a fake RPC and make that RPC consistently report whatever chain id, receipt, log, or contract state helps the attack.
Use a source-level policy instead:
1. Hardcode the expected RPC hostnames in the action source.
2. Require `https://` so TLS binds the request to that hostname.
3. Reject redirects, so a whitelisted host cannot bounce the action to attacker-controlled JSON-RPC.
4. For multi-chain actions, map each supported `chainId` to its allowed host and any chain-specific safety policy, such as minimum confirmations.
```javascript theme={null}
const RPC_HOSTS = {
84532: { host: /^base-sepolia\.g\.alchemy\.com$/i, minConfirmations: 5 },
421614: { host: /^arb-sepolia\.g\.alchemy\.com$/i, minConfirmations: 5 },
};
function assertAllowedRpc(chainId, rpcUrl) {
const policy = RPC_HOSTS[Number(chainId)];
if (!policy) throw new Error(`chainId ${chainId} not whitelisted`);
const parsed = new URL(rpcUrl);
if (parsed.protocol !== "https:") throw new Error("RPC URL must use https://");
if (!policy.host.test(parsed.hostname)) throw new Error("RPC host not whitelisted");
return policy;
}
async function rpc(url, method, params) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
redirect: "error",
});
const body = await res.json();
if (body.error) throw new Error(body.error.message);
return body.result;
}
```
Because the whitelist is part of the action code, changing providers or adding chains changes the IPFS CID and therefore the action-derived signer address. That is a feature: contracts that verify action-identity signatures are explicitly trusting this exact code and these exact external data sources.
Helpers like `assertAllowedRpc` and `rpc` are exactly the kind of logic worth sharing across actions. Instead of copy-pasting them into every action, publish them as a version-pinned npm package and import them — see [Composability — Publish Your Own Packages](/lit-actions/imports#composability-publish-your-own-packages).
***
## Multi-Source Consensus
When a Lit Action signs data from outside the chain it is serving, decide how independent sources should agree before any signature is produced. The right aggregation depends on the data shape:
* **Discrete facts** such as `isSanctioned(address) -> bool`, event presence, or yes/no market outcomes should usually require strict agreement across independent sources.
* **Continuous values** such as market prices should usually use a median or trimmed mean plus a maximum-spread check, because honest sources will naturally differ by small amounts.
* **Fallback availability** should fail closed: if too few sources respond, return an unsigned denial instead of signing from a single source.
Hardcode the source list, minimum source count, and spread/agreement policy in the action. If a caller can lower `MIN_SOURCES`, widen a spread cap, or choose arbitrary sources via `js_params`, the policy is no longer the trust anchor.
```javascript theme={null}
const SOURCES = ["source-a", "source-b", "source-c"];
const MIN_SOURCES = 2;
async function requireStrictAgreement(fetchSource) {
const settled = await Promise.allSettled(SOURCES.map(fetchSource));
const ok = settled
.filter((r) => r.status === "fulfilled" && r.value != null)
.map((r) => r.value);
if (ok.length < MIN_SOURCES) return { authorized: false, reason: "too few sources" };
if (!ok.every((value) => value === ok[0])) {
return { authorized: false, reason: "sources disagree", values: ok };
}
return { authorized: true, value: ok[0] };
}
```
For continuous values, sort the successful observations, take the median, and refuse to sign if `(max - min) / median` exceeds a hardcoded spread cap.
***
## 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.
### A Wallet Bound to the Action — and a Unique One Per User
The flip side of "edit a byte, get a new key" is that you can use the action-derived key as a **wallet that can only ever be operated by this exact code**. The key from `getLitActionPrivateKey()` is deterministically derived from the CID and never leaves the TEE, so the action *is* the only thing that can sign for that address. This is a lightweight alternative to converting an account to ChainSecured and using a contract to mint and bind PKPs to a group — you get the same "only this code can sign" property without minting a PKP.
To give **each user their own** such wallet, make the code differ per user by hardcoding the user's address into the action:
```javascript theme={null}
const OWNER_ADDRESS = "0xUSERS_ADDRESS"; // stamped in per user
async function main({ action, to, amount, signature, message }) {
// Derived from THIS action's CID — so a different OWNER_ADDRESS yields a
// different CID and therefore a different, immutable wallet per user.
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
if (action === "address") return { walletAddress: wallet.address };
// Spending is gated on the owner's signature, not on who ran the action.
if (ethers.utils.verifyMessage(message, signature).toLowerCase() !== OWNER_ADDRESS.toLowerCase()) {
throw new Error("unauthorized: signer is not the bound owner");
}
// ... sign the transfer/transaction the owner authorized
}
```
Because `OWNER_ADDRESS` is part of the hashed source, two users produce two CIDs, two keys, and two wallet addresses — there is no code path from one user's action to another's wallet. Authorize spending by recovering a signature and comparing it to the hardcoded owner (include a nonce/deadline in the signed `message` for replay protection, as in the [caller-signature check above](#what-if-someone-else-runs-my-action)). The Lit usage key that *runs* the action grants no spending power — it can only read the address or relay a withdrawal the real owner already signed.
A full runnable version — deposit an ERC-20 into the wallet, withdraw by signing, and a wrong-user attack the action refuses — is in [`examples/action-bound-wallet/`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/action-bound-wallet) (walkthrough in [Examples §13](./examples#13-a-unique-immutable-wallet-per-user-bound-to-the-action)).
***
## Encrypt / Decrypt — PKP Wallets as Data Vaults
For the full walkthrough on handling secrets — minting a vault PKP, permissioning actions via groups, encrypt/decrypt lifecycle, storage, and rotation — see the [Secrets guide](/lit-actions/secrets). This section focuses on the design pattern of using a PKP as a vault and gating decrypt access for dApp users.
### 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.
***
## Multiple PKPs in a Single Action — Separating App Secrets from User Signing
Nothing limits a Lit Action to one PKP. Every `getPrivateKey`, `Encrypt`, and `Decrypt` call names a `pkpId`, so a single execution can use **several PKPs for different purposes** — as long as each PKP and the action's IPFS CID are in the same [group](/architecture/groups). This unlocks a pattern that is often warranted in production: use one PKP as an **app-owned vault** that guards shared secrets like third-party API keys, and give each end user their **own discrete PKP** for signing.
The two roles have opposite sharing models, which is exactly why they belong in separate PKPs:
| PKP | Role | Who it belongs to |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| **App-secrets PKP** | Holds the ciphertext of an API key (or other credential) the app must use on every call. Its ID is **hardcoded** in the action. | The app. One vault, shared across all users. |
| **User PKP** | Signs on behalf of one user. Its ID is passed via `js_params`. | The user. One discrete wallet per user. |
Keeping them separate means a user's signing wallet never has any relationship to your API key, and rotating or revoking one user's PKP has no effect on the shared secret or on any other user.
### The pattern
The action below does both jobs in one execution: it decrypts a shared API key from the **app-secrets PKP**, calls an external service with it, then signs the result with the **caller's own PKP**.
```javascript theme={null}
// js_params: { userPkpId, payload }
// userPkpId is the caller's discrete signing PKP — the gateway has already
// verified it belongs to the caller's account and is permitted in this group.
async function main({ userPkpId, payload }) {
// Hardcode the app-secrets PKP so a caller cannot substitute their own vault.
const APP_SECRETS_PKP = "0xAppSecretsVaultAddress";
// Ciphertext of the shared API key, sealed once against APP_SECRETS_PKP.
// Bake it in (as here) or pass it from your backend — it is opaque without the key.
const ENCRYPTED_API_KEY = "";
// 1. Decrypt the shared secret inside the TEE using the APP's PKP.
const apiKey = await Lit.Actions.Decrypt({
pkpId: APP_SECRETS_PKP,
ciphertext: ENCRYPTED_API_KEY,
});
// 2. Use the secret to do app-level work — the key never leaves the enclave.
const res = await fetch("https://api.your-service.com/process", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await res.json();
// 3. Sign the result with the USER's own PKP — a wallet the app never controls.
const userWallet = new ethers.Wallet(
await Lit.Actions.getPrivateKey({ pkpId: userPkpId })
);
const signature = await userWallet.signMessage(JSON.stringify(result));
// Return the signed result — never the API key.
return { result, signature, signer: userWallet.address };
}
```
### Why this is safe
* **The app secret is hardcoded, not caller-supplied.** `APP_SECRETS_PKP` is baked into the action source, so it is part of the immutable IPFS CID. A caller cannot point the decrypt at a different vault, and because the ciphertext only decrypts against that PKP inside the TEE, the plaintext key never reaches the caller. See [Securing RPC URLs](#securing-rpc-urls--hiding-api-keys-with-encryption) and the [Secrets guide](/lit-actions/secrets) for the vault lifecycle.
* **The user PKP is caller-supplied, but ownership-bound.** `js_params` are untrusted, but the gateway only lets a caller use PKPs that belong to their account and are permitted in the group — so passing `userPkpId` cannot reach another user's wallet or the app-secrets vault. If you want defense-in-depth, gate the user's request with a signature check, as in [Gating Logic](#gating-logic--aka-access-control-conditions).
* **All referenced PKPs must be in the group.** Both `APP_SECRETS_PKP` and every user PKP have to be members of the same group as the action's IPFS CID, or the `Decrypt` / `getPrivateKey` call is rejected before it runs.
### Grouping the PKPs
```
Account
└── Group
├── IPFS CID: "process-and-sign" action ← the code above
├── PKP: "app-secrets" ← shared vault, hardcoded in the action
├── PKP: "user-alice-signer" ← one discrete signer per user
├── PKP: "user-bob-signer"
└── Usage API Key ← triggers the action
```
Add each user's signing PKP to the group as you onboard them; the single `app-secrets` PKP stays constant. Because the action code references the app vault by a hardcoded address and the user vault by parameter, you never republish the action to add a user — you only update group membership. See the [Groups guide](/architecture/groups) for how to manage membership.
This is the multi-PKP generalization of two single-PKP patterns already covered here: [PKP Wallets as Data Vaults](#encrypt--decrypt--pkp-wallets-as-data-vaults) (the app-secrets side) and [A Wallet Bound to the Action — and a Unique One Per User](#a-wallet-bound-to-the-action--and-a-unique-one-per-user) (the per-user side). Reach for it whenever one execution needs both a shared, app-controlled credential and a per-user signing identity.
# Secrets
Source: https://docs.dev.litprotocol.com/lit-actions/secrets
How to use secrets — API keys, tokens, credentials — inside a Lit Action. The PKP-as-vault model, the encrypt-once / decrypt-at-runtime lifecycle, where to store ciphertexts, and how to rotate.
A "secret" in a Lit Action is any string you want the action to use at runtime but never want exposed to callers, observers, or the public IPFS file that holds your action source. Typical examples: third-party API keys (OpenAI, Alchemy, Stripe), database connection strings, signing keys for external services, OAuth client secrets.
Chipotle has no separate secrets store. Secrets are handled with the same primitive as any encrypted data: a PKP wallet acts as a vault, and any Lit Action permitted to use that PKP can call `Decrypt` to recover the plaintext. Security comes from **controlling which actions are permitted to use the vault PKP** — the immutable IPFS CID lets you (and anyone else) audit that the permitted code doesn't return or log the plaintext.
## The model
| Concept | What it is |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **Vault PKP** | A PKP wallet dedicated to encrypting a set of related secrets. Only actions you've permitted to use this PKP can decrypt its ciphertexts. |
| **Ciphertext** | The output of `Lit.Actions.Encrypt({ pkpId, message })`. Safe to store anywhere — IPFS, a database, on-chain, baked into the action source. |
| **Decrypt action** | A Lit Action permitted to use the vault PKP. It calls `Lit.Actions.Decrypt` to recover plaintext, uses it, and returns only the result. |
The trust anchor is the on-chain configuration that says "this IPFS CID may use this PKP." Anyone who can't get the gateway to run that exact code against that exact PKP cannot decrypt the ciphertext, no matter what else they hold. A permitted action *can* exfiltrate the plaintext if its code chooses to — so what matters is that you only permit code you've audited.
## Lifecycle: encrypt once, decrypt at runtime
### 1. Mint a vault PKP
Create a PKP for your secrets through the [Dashboard](/management/dashboard) or via [`createWallet`](/management/api_direct). Give it a name that describes the data boundary it protects — e.g. `app-secrets`, `user-alice-vault`, `oracle-api-keys`.
One PKP per logical data boundary is the recommended pattern. See [PKP Wallets as Data Vaults](/lit-actions/patterns#encrypt--decrypt--pkp-wallets-as-data-vaults) for the rationale.
### 2. Permit your actions to use the PKP via a group
Permissioning happens through **groups**: a group binds a set of PKPs, a set of permitted IPFS action CIDs, and the usage API keys that can run them together. An action can only call `Encrypt` or `Decrypt` against a PKP when both the action's IPFS CID and the PKP are in the same group.
You need to add to the group:
* The **vault PKP** from step 1.
* The **IPFS CID of the encrypt action** you'll run in step 3.
* The **IPFS CID of every production action** that will decrypt the secret at runtime (step 4).
* The **usage API key** that will trigger these actions.
Manage groups through the [Dashboard](/management/dashboard) or directly via the `AccountConfig` contract. See the [Groups guide](/architecture/groups) for the full model.
If you publish a new version of your decrypt action, its IPFS CID changes — you'll need to add the new CID to the group (and remove the old one if you want to retire it).
### 3. Encrypt the secret once
Run an action that returns the ciphertext. You only run this when the secret changes.
```javascript theme={null}
// js_params: { pkpId, secret }
async function main({ pkpId, secret }) {
const ciphertext = await Lit.Actions.Encrypt({ pkpId, message: secret });
return { ciphertext };
}
```
The returned `ciphertext` is opaque without the vault PKP. Store it wherever fits your app:
* Bake it into your production action source code (ciphertext becomes part of the immutable IPFS CID)
* Pass it via `js_params` from your backend
* Store it in a database or on-chain registry alongside metadata
### 4. Decrypt at runtime in your production action
Your production action receives the ciphertext, decrypts, uses the plaintext, and returns only the *result* — never the secret itself.
```javascript theme={null}
// js_params: { pkpId, encryptedApiKey, city }
async function main({ pkpId, encryptedApiKey, city }) {
const apiKey = await Lit.Actions.Decrypt({
pkpId,
ciphertext: encryptedApiKey,
});
const res = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${apiKey}`
);
const data = await res.json();
// Return only what the caller is allowed to see — not the API key.
return { temp: data?.main?.temp };
}
```
The plaintext exists in memory only during the call, and only inside the action code you wrote and permitted. It's on your action not to return, log, or otherwise leak it.
## Where to put the ciphertext
The ciphertext is safe in the open. Pick the storage option that matches how the secret is consumed:
| Storage | When to use |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Hardcoded in the action source** | The secret rarely changes and you want it pinned to a specific action CID. Rotating the secret mints a new CID. |
| **`js_params` passed by your backend** | Multiple secrets, or secrets that change without redeploying the action. Your backend stores the ciphertext and supplies it per call. |
| **On-chain registry contract** | You want anyone (or a permissioned set) to be able to fetch the current ciphertext. |
| **IPFS / database** | Bulk storage of many ciphertexts (e.g. one per user), addressable by some key. |
In all cases, callers can hold and pass the ciphertext freely — the gating point is whether they can get your action to run against the vault PKP.
## Multiple secrets
You have two natural shapes. Pick the one that matches how the secrets are *used together*, not how they're stored.
**One secret per ciphertext.** Encrypt each secret as a separate call. Pass only the ones you need.
```javascript theme={null}
// js_params: { pkpId, encryptedOpenAiKey, encryptedAnthropicKey, prompt }
async function main({ pkpId, encryptedOpenAiKey, encryptedAnthropicKey, prompt }) {
const [openaiKey, anthropicKey] = await Promise.all([
Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedOpenAiKey }),
Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedAnthropicKey }),
]);
// ... use both keys
}
```
**Bundled JSON in one ciphertext.** Useful when secrets are always used together (e.g. an OAuth `client_id` + `client_secret` pair, or an API key + endpoint URL).
```javascript theme={null}
// Encrypt once: JSON.stringify({ clientId, clientSecret }) → ciphertext
// Decrypt at runtime:
const bundle = JSON.parse(
await Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedBundle })
);
const { clientId, clientSecret } = bundle;
```
The bundled form means one decrypt call instead of N, at the cost of having to re-encrypt the whole bundle to rotate any single field.
## Rotating a secret
A ciphertext is bound to the vault PKP, not to the secret value. Rotating means re-encrypting the new secret against the same PKP and replacing the old ciphertext wherever it's stored.
```
1. Run the encrypt action with the new secret value.
2. Replace the stored ciphertext (in your DB, registry, or action source).
3. Old ciphertexts continue to decrypt to the old value — invalidate them
on the upstream system (revoke the old API key, etc.).
```
You do not need to rotate the PKP unless you suspect the on-chain permission set has been compromised.
If the ciphertext is **hardcoded in the action source**, rotation mints a new IPFS CID. Update any contracts or callers that pin to the old CID.
## Securing an RPC URL with an embedded API key
A common case worth calling out: many RPC providers require the API key in the URL itself (e.g. `https://mainnet.infura.io/v3/YOUR_KEY`). Passing the full URL through `js_params` would leak the key. Instead, hardcode the base URL in the action (so observers can verify the target chain) and decrypt just the key at runtime.
See [Securing RPC URLs](/lit-actions/patterns#securing-rpc-urls--hiding-api-keys-with-encryption) for the full pattern.
## Common mistakes
* **Returning the plaintext secret in the action response.** Whatever you `return` from `main` reaches the caller. Decrypt, use, and return only the *result* of using the secret.
* **Logging the plaintext via `console.log`.** Lit Action logs are visible to the caller.
* **Passing the unencrypted secret via `js_params`.** `js_params` are caller-supplied and visible in the request — exactly the wrong place for a secret. Only ciphertexts and identifiers belong there.
* **Sharing one vault PKP across unrelated apps.** If an attacker convinces the on-chain config to permit a malicious action against the vault PKP, every secret in that vault is exposed. One PKP per concern keeps blast radius small.
* **Forgetting that ciphertext baked into action source is immutable.** Once the action is published to IPFS, you can't edit the embedded ciphertext — rotate by publishing a new action.
## What's enforced where
| Guarantee | Enforced by |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Only permitted IPFS CIDs can use the vault PKP | On-chain `AccountConfig` contract via group membership |
| The action code that runs is exactly the IPFS CID requested | IPFS content addressing + node verification |
| Who can *call* the action | Your usage API key's group access + your action's own gating logic |
| The plaintext is never exposed by a permitted action | **You** — by auditing the action source against its IPFS CID before adding it to the group |
The first three are network-level guarantees. The fourth — auditing what the permitted code actually does with the plaintext, and who is allowed to trigger a decrypt — is your responsibility. See [Gating Logic](/lit-actions/patterns#gating-logic--aka-access-control-conditions) for patterns.
## See also
* [Encrypt / Decrypt — PKP Wallets as Data Vaults](/lit-actions/patterns#encrypt--decrypt--pkp-wallets-as-data-vaults) — the broader vault pattern, including gated decrypt actions for dApp users.
* [Multiple PKPs in a Single Action](/lit-actions/patterns#multiple-pkps-in-a-single-action--separating-app-secrets-from-user-signing) — use one PKP as a shared secrets vault while giving each end user a discrete PKP for signing, all in one execution.
* [Securing RPC URLs](/lit-actions/patterns#securing-rpc-urls--hiding-api-keys-with-encryption) — the canonical embedded-API-key walkthrough.
* [Lit Actions SDK reference](/lit-actions/chipotle#encryption) — `Encrypt` / `Decrypt` API.
* [Encryption migration notes](/lit-actions/migration/encryption) — how Chipotle's TEE-derived encryption differs from the older BLS threshold model.
# WebAssembly (WASM)
Source: https://docs.dev.litprotocol.com/lit-actions/wasm
Lit Actions can load and run WebAssembly in the runtime — so real cryptography (threshold ECDSA, ZK), parsers, and anything compiled from Rust, C, C++, or Go runs inside the action.
## WASM runs inside a Lit Action
The Lit Action runtime is Deno-based, so the standard `WebAssembly` API is
available alongside the web platform globals an action already has (`fetch`,
`CompressionStream`, `crypto`, `TextEncoder`, …). That means you can run a
WebAssembly module directly inside the action — no native add-ons, no separate
service. Anything that compiles to wasm (Rust, C/C++, Go, AssemblyScript) and
anything published as a wasm-bindgen package on npm works.
This is what makes heavyweight, audited cryptography practical inside an action:
the [mpc-signing-ecdsa example](./examples#16-non-custodial-co-signer-threshold-ecdsa-split-between-lit-and-you)
runs the [DKLs23](https://dkls.info/) threshold-ECDSA protocol (a Trail-of-Bits-audited
Rust library compiled to wasm) entirely inside the action to co-sign with the user,
and the [mpc-signing-frost example](./examples#18-non-custodial-co-signer-threshold-frost-for-solana-bitcoin-and-zcash)
does the same with threshold **FROST** (the Kudelski-audited `lit-frost` + `frost-dkg`,
compiled to wasm) for Schnorr/EdDSA chains like Solana.
## Loading a module
There are two ways to get the wasm bytes into the runtime.
### 1. Import the glue, fetch the wasm at runtime
Most wasm-bindgen packages ship a small JS "glue" module plus a `.wasm` binary.
[Import the glue from jsDelivr](./imports) (pinned + SHA-384 verified like any
other import), then `fetch` the `.wasm` bytes and hand them to `initSync`:
```javascript theme={null}
import { initSync, /* your exported types */ } from
"@silencelaboratories/dkls-wasm-ll-web@1.2.0/dkls-wasm-ll-web.js";
const WASM_URL =
"https://cdn.jsdelivr.net/npm/@silencelaboratories/dkls-wasm-ll-web@1.2.0/dkls-wasm-ll-web_bg.wasm";
let ready = false;
async function ensureWasm() {
if (ready) return;
const res = await fetch(WASM_URL); // pull the .wasm bytes
if (!res.ok) throw new Error(`fetch wasm ${res.status}`);
initSync(new Uint8Array(await res.arrayBuffer())); // instantiate the module
ready = true;
}
async function main(params) {
await ensureWasm();
// ...now call into the wasm-backed API...
}
```
### 2. Inline the wasm as base64
For maximum trust, base64-encode the `.wasm` and embed it in the action source,
then decode and `initSync` it. This removes the runtime fetch and makes the
action's **IPFS CID commit to the exact crypto bytes** — there is no external
dependency to resolve at run time:
```javascript theme={null}
const WASM_B64 = "AGFzbQEAAAA..."; // the .wasm, base64-inlined
initSync(Uint8Array.from(atob(WASM_B64), (c) => c.charCodeAt(0)));
```
jsDelivr is immutable at a pinned version and integrity-checked, so option 1 is
safe for most uses. Option 2 is the tighter setup when you want the CID itself
to attest to the precise bytes that ran (e.g. so a verifier doesn't have to
trust the CDN at all). The trade-off is action size — a large module inlined as
base64 grows the source \~33%, and an action that exceeds the request-body limit
can't be submitted at all.
**Middle ground (option 1 + a pinned hash).** When the module is too big to
inline but you still want the CID to commit to the crypto, fetch the wasm and
verify its SHA-256 against a constant in the action before `initSync`-ing it —
the CID commits to the hash, so the action refuses to run any other bytes:
```javascript theme={null}
const WASM_SHA256 = "6b7eda…51dc"; // pinned; the CID commits to this
const bytes = new Uint8Array(await (await fetch(WASM_URL)).arrayBuffer());
const hex = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
.map((b) => b.toString(16).padStart(2, "0")).join("");
if (hex !== WASM_SHA256) throw new Error("wasm hash mismatch — refusing to run");
initSync({ module: bytes });
```
The mpc-signing-frost example uses exactly this — its \~1.5 MB FROST module is too
large to inline, so it pins the hash instead.
## Things to keep in mind
* **It's Deno, not Node.** You get web APIs (`fetch`, streams, `WebAssembly`,
`crypto`), not Node built-ins. Use the **web** build of a wasm-bindgen package
(e.g. `…-web`), not the `…-node` build.
* **Size and response limits.** A big module plus its working state count against
action size and the response-payload cap — see [Limits](./limits). The
mpc-signing-ecdsa example relays a large sealed session each round, well within
the default response cap.
* **Stateless across calls.** An action holds no memory between invocations, so a
wasm session that must span multiple calls has to be serialized out and passed
back in (mpc-signing-ecdsa seals its session with `Lit.Actions.Encrypt` and relays
it through the user each round).
## See it run
The [mpc-signing-ecdsa example](https://github.com/LIT-Protocol/chipotle/tree/main/examples/mpc-signing-ecdsa)
runs DKLs23 threshold ECDSA in wasm inside the action: it instantiates the wasm,
serializes and rebuilds the module's session between every protocol round (the
stateless-relay pattern), and produces a signature plain `ecrecover` accepts. Its
`action/mpcSigner.js` is a working template for getting any wasm module running in
an action.
# Examples
Source: https://docs.dev.litprotocol.com/lit-triggers/examples
Copy-paste Lit Action examples for triggers — echo a webhook payload, notarize it with a keyless signature — plus links to full runnable demos with contracts, setup, and end-to-end clients.
The actions below are complete: paste the code as a trigger's `action_code`
(see [Creating Triggers](/lit-triggers/triggers)). For flows that need a
contract, a deploy step, and an off-chain client, see the
[full demos](#full-demos).
***
## 1. Echo a webhook payload
The simplest trigger action — returns what it received. Useful to confirm a
webhook is wired up and to see the exact `params` shape.
```javascript theme={null}
const main = async (params) => {
return {
ok: true,
received_at: new Date().toISOString(),
source: (params && params.source) || null,
event: (params && params.event) || null,
header_keys: params && params.headers ? Object.keys(params.headers) : [],
};
};
```
Create it as a webhook trigger, then `POST /webhook/` with any JSON body —
the body comes back under `event`, and the safe request headers under
`header_keys`.
***
## 2. Notarize a payload with a keyless signature
Take any webhook payload, compute a deterministic digest, and sign it with the
action's own wallet — a key held by the Lit network, not by any server. The
result is a tamper-evident receipt that only this exact action code could have
produced. This is the building block the on-chain demos extend.
```javascript theme={null}
// Deterministic JSON (sorted keys, recursive) so any verifier reproduces
// the exact bytes that were signed.
const stableStringify = (v) => {
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
if (v && typeof v === "object") {
return "{" + Object.keys(v).sort()
.map((k) => JSON.stringify(k) + ":" + stableStringify(v[k]))
.join(",") + "}";
}
return JSON.stringify(v);
};
const main = async (params) => {
const payload = (params && params.event) || {};
const digest = ethers.utils.id(stableStringify(payload)); // keccak256(utf8)
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
const signature = await wallet.signMessage(ethers.utils.arrayify(digest));
return { signer: wallet.address, digest, signature, payload };
};
```
Verify a receipt anywhere:
```javascript theme={null}
const recovered = ethers.utils.verifyMessage(ethers.utils.arrayify(digest), signature);
// recovered === signer → payload is authentic and unmodified
```
***
## 3. Sign a scheduled heartbeat
A schedule trigger that signs a timestamped heartbeat each tick — a minimal
"signed cron" you can post on-chain or to an external monitor.
```javascript theme={null}
const main = async (params) => {
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey());
const message = `heartbeat ${params.scheduled_at}`;
return {
cron: params.cron,
scheduled_at: params.scheduled_at,
signer: wallet.address,
signature: await wallet.signMessage(message),
};
};
```
***
## 4. React to a chain event
A chain-event trigger that reads ABI-decoded args from a matched log. Pair the
`Transfer(address,address,uint256)` signature with an ERC-20 contract to watch
transfers; `decoded.arg2` is the amount.
```javascript theme={null}
const main = async (params) => {
const e = params.event;
return {
chain: e.chain_key,
tx: e.transaction_hash,
from: e.decoded.arg0,
to: e.decoded.arg1,
amount: e.decoded.arg2,
};
};
```
This reads `decoded` directly, which is fine for **observing** (notifying,
logging). But anyone with the usage key can run the action with a fabricated
`decoded` payload, so do **not** sign or transact on these values as-is. If the
action acts on the event, re-fetch the log by `transaction_hash` + `log_index`
from a pinned source RPC and verify the emitter — the
[hostname-pinned RPC trust-anchor pattern](/lit-actions/patterns), as the
`chainlink-feed-mirror` demo below does.
***
## Full demos
These need more than one file to run — a Solidity contract, a deploy script,
an end-to-end client — and live under
[`examples/lit-triggers/` in the repo](https://github.com/LIT-Protocol/chipotle/tree/main/examples/lit-triggers):
| Example | Trigger | What it shows |
| ------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------- |
| [`release-attestation`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/lit-triggers/release-attestation) | webhook | Verify a GitHub release webhook (HMAC over the raw body), then anchor the release on-chain via a keyless signer. |
| [`uptime-insurance`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/lit-triggers/uptime-insurance) | schedule | Parametric insurance: an autonomous ETH payout from a pool key nobody holds when a monitored service is down. |
| [`chainlink-feed-mirror`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/lit-triggers/chainlink-feed-mirror) | chain\_event | Relay a Chainlink price feed to a chain Chainlink doesn't support, with no trusted relayer. |
Each ships a hardened action, a one-shot `setup` script (action CID, scoped key,
contract deploy where applicable, trigger creation), and an end-to-end client.
(`uptime-insurance` has no contract — its "pool" is the action wallet's balance.)
Want an agent to wire any of these up for you? Point it at
[`https://triggers.litprotocol.com/SKILL.md`](https://triggers.litprotocol.com/SKILL.md).
# Overview
Source: https://docs.dev.litprotocol.com/lit-triggers/index
Lit Triggers runs a Lit Action when something happens — an inbound webhook, a cron tick, or a matching EVM chain event — turning an action into an autonomous, event-driven agent that can fetch data, sign, and transact with a key no human holds.
## What is Lit Triggers?
A [Lit Action](/lit-actions/index) runs when you call it. **Lit Triggers** runs one for you when something happens:
* **Webhook** — an external service POSTs to a generated URL.
* **Schedule** — a cron expression fires the action automatically.
* **Chain event** — an EVM log matching a chain / contract / event signature fires the action.
The service is hosted at:
```text theme={null}
https://triggers.litprotocol.com
```
**Setting this up with an AI agent?** Hand it
[`https://triggers.litprotocol.com/SKILL.md`](https://triggers.litprotocol.com/SKILL.md) —
a machine-readable guide that walks any coding agent through authorizing,
creating webhook/schedule/chain-event triggers, and inspecting runs on your
behalf.
Because the action signs with a key derived from its own IPFS CID (via
`Lit.Actions.getLitActionPrivateKey()`), a trigger turns a Lit Action into an
**autonomous actor**: it reacts to an event, evaluates trusted data, and signs
or sends a transaction — with no server or human holding the signing key, and
no separate keeper or oracle to trust. Edit the action by a byte and its CID,
signer address, and every downstream authorization change with it.
## How it fits together
```
trigger source lit-triggers Lit network (Chipotle)
───────────── ──────────── ──────────────────────
webhook POST ─┐
cron tick ├──► match → enqueue run ──► POST /core/v1/lit_action ──► main(params)
chain event ─┘ (your scoped usage key) │ fetch / sign / tx
▼
run history ◄────────────── response ◄────────────── return value
```
You create a trigger with the action code, a trigger config (webhook / cron /
chain event), and a **scoped Chipotle usage API key** that is allowed to execute
the action. When the trigger fires, lit-triggers enqueues a run and dispatches it
to the Lit network, which runs your action and returns the result. Every run is
recorded with its input, status, and response.
## The action contract
The runtime **wraps your code and invokes `main(params)` itself**, then wraps the
returned value in `Lit.Actions.setResponse()`. Define `main` and return a value —
do not call `main()` yourself.
```javascript theme={null}
const main = async (params) => {
// params is the trigger payload (shape depends on the trigger type)
return { ok: true };
};
```
The shape of `params` depends on the trigger type:
| Trigger | `params` |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| webhook | `{ source: "webhook", event: , event_raw: , headers: { ... } }` |
| schedule | `{ source: "schedule", scheduled_at: "", cron: "" }` |
| chain\_event | `{ source: "chain_event", event: { chain_key, chain_id, decoded: { arg0, arg1, ... }, raw_log, transaction_hash, log_index, topic0, topics, block_number, ... } }` |
Inside the action you have `ethers` (v5), `fetch`, `crypto`, and the
[`Lit.Actions` SDK](/lit-actions/chipotle) (`getLitActionPrivateKey`,
`getLitActionWalletAddress`, `Encrypt`, `Decrypt`, `setResponse`). `viem` is not
available — use `ethers`.
## Security model
* Your Lit/Chipotle **admin API key stays with you** — never send it to the
lit-triggers backend.
* lit-triggers stores only **scoped usage API keys**, encrypted at rest. A scoped
key must be permitted to execute the target action's group. Mint one in the
[Dashboard](https://dashboard.chipotle.litprotocol.com) or via the API.
* The action's **signing key is never configured** — it is derived from the
action's CID by the Lit network at run time.
* For secrets the action needs (e.g. a webhook HMAC secret), prefer
`Lit.Actions.Encrypt`/`Decrypt` over plaintext trigger params.
## Next steps
* [Creating Triggers](/lit-triggers/triggers) — authorize an agent, create
webhook / schedule / chain-event triggers, inspect runs.
* [Examples](/lit-triggers/examples) — copy-paste actions, plus links to full
runnable demos (contracts + setup + e2e) in the repo.
# Creating Triggers
Source: https://docs.dev.litprotocol.com/lit-triggers/triggers
Authorize access, create webhook / schedule / chain-event triggers, fire them, and inspect run history against the Lit Triggers API.
All API calls are authenticated with a bearer token and target
`https://triggers.litprotocol.com`. Every trigger needs a **scoped Chipotle
usage API key** permitted to execute the action — mint one in the
[Dashboard](https://dashboard.chipotle.litprotocol.com) or via the management API.
If you are an AI agent setting this up on a user's behalf, a machine-readable
guide lives at [`https://triggers.litprotocol.com/SKILL.md`](https://triggers.litprotocol.com/SKILL.md).
It covers the browser-based authorization handshake step by step.
## Authorize
Access is granted from a logged-in browser session. Generate a local random
bearer token, build an authorization URL containing only a hash challenge of it,
and have the user approve it in the browser:
```bash theme={null}
# 1. create a local token
mkdir -p ~/.lit-triggers
python3 - <<'PY'
import pathlib, secrets
p = pathlib.Path.home() / '.lit-triggers' / 'agent-token'
if not p.exists():
p.write_text(secrets.token_urlsafe(48)); p.chmod(0o600)
print(p.read_text().strip())
PY
# 2. build the authorize URL (open in a logged-in browser, click "Authorize")
python3 - <<'PY'
import base64, hashlib, pathlib, urllib.parse
raw = (pathlib.Path.home() / '.lit-triggers' / 'agent-token').read_text().strip()
challenge = base64.urlsafe_b64encode(hashlib.sha256(raw.encode()).digest()).rstrip(b'=').decode()
print('https://triggers.litprotocol.com/agent/authorize?' + urllib.parse.urlencode({'challenge': challenge}))
PY
```
The raw token never leaves your machine — only its SHA-256 challenge is in the
URL. After approval, the token works as `Authorization: Bearer `:
```bash theme={null}
curl -fsS -H "authorization: Bearer $TOKEN" https://triggers.litprotocol.com/api/me
# { "id": "...", "email": "you@example.com" }
```
## Create a webhook trigger
An external service POSTs JSON (or text) to a generated URL; each POST fires a run.
```bash theme={null}
curl -fsS -X POST https://triggers.litprotocol.com/api/triggers \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"name": "my-webhook",
"kind": "webhook",
"action_code": "const main = async (params) => ({ ok: true, event: params.event });",
"default_params": {},
"usage_api_key": "",
"max_runs_per_minute": 10,
"max_queued_runs": 20,
"config": {}
}'
# -> { "id": "", ... }
```
Fire it at `POST /webhook/` (public; returns `202` with a run id):
```bash theme={null}
curl -fsS -X POST https://triggers.litprotocol.com/webhook/ \
-H 'content-type: application/json' -d '{"hello":"world"}'
```
The action receives the parsed body as `params.event`, the exact raw bytes as
`params.event_raw`, and safe headers as `params.headers`. Verification headers
(`x-hub-signature-256`, `x-github-event`, `stripe-signature`,
`x-slack-signature`, …) are passed through so you can verify the sender;
secret-bearing headers (`authorization`, `cookie`, `x-api-key`) are stripped.
## Create a schedule trigger
`config.cron` is a 5-field cron (or 6-field with seconds). Sub-30-second
schedules are rejected — the scheduler scans every 30 seconds.
```bash theme={null}
curl -fsS -X POST https://triggers.litprotocol.com/api/triggers \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"name": "every-5-min",
"kind": "schedule",
"action_code": "const main = async (params) => ({ ranAt: params.scheduled_at });",
"usage_api_key": "",
"config": { "cron": "*/5 * * * *" }
}'
```
Schedule runs pass `params` flat: `{ source: "schedule", scheduled_at, cron }`.
## Create a chain-event trigger
Fires when a log matching the contract + event signature appears on a supported
chain: `ethereum`, `base`, `arbitrum`, `bsc`, `polygon`. The deployment must
have the chain's RPC configured.
```bash theme={null}
curl -fsS -X POST https://triggers.litprotocol.com/api/triggers \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"name": "base-usdc-transfers",
"kind": "chain_event",
"action_code": "const main = async (p) => ({ from: p.event.decoded.arg0, amount: p.event.decoded.arg2 });",
"usage_api_key": "",
"config": {
"chain": "base",
"contract_address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"event_signature": "Transfer(address,address,uint256)"
}
}'
```
Optional config:
* `start_block` — integer or hex string to backfill from.
* `topic_filters` — up to three entries after `topic0`; each is a 32-byte topic,
an array of topics, or `null` (wildcard).
Chain-event runs include ABI-decoded args (`event.decoded.arg0`, `arg1`, …)
alongside the raw log, transaction hash, block number, and topics.
## Inspect and manage
```bash theme={null}
# list / get
curl -fsS -H "authorization: Bearer $TOKEN" https://triggers.litprotocol.com/api/triggers
curl -fsS -H "authorization: Bearer $TOKEN" https://triggers.litprotocol.com/api/triggers/
# recent runs (input, status, response, error)
curl -fsS -H "authorization: Bearer $TOKEN" \
"https://triggers.litprotocol.com/api/triggers//runs?limit=20"
# disable (stop firing) / re-enable
curl -fsS -X PATCH -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"enabled": false}' https://triggers.litprotocol.com/api/triggers/
# delete
curl -fsS -X DELETE -H "authorization: Bearer $TOKEN" \
https://triggers.litprotocol.com/api/triggers/
```
A run progresses `queued` → `running` → `success` | `failed`. Transient (5xx)
failures from the Lit network are retried up to 3 times with backoff; the run's
`response`/`error` captures the action's output or the failure (including a JS
stack trace when the action throws).
## API reference
| Method & path | Purpose |
| ----------------------------- | ------------------------------------------ |
| `GET /api/me` | Identity check for the bearer token |
| `POST /api/triggers` | Create a trigger |
| `GET /api/triggers` | List triggers |
| `GET /api/triggers/` | Get one trigger |
| `PATCH /api/triggers/` | Update (e.g. `enabled`) |
| `DELETE /api/triggers/` | Delete a trigger |
| `GET /api/triggers//runs` | Run history (`?limit=&offset=`) |
| `POST /webhook/` | Public webhook endpoint (webhook triggers) |
# API Mode vs ChainSecured Mode
Source: https://docs.dev.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 — admin writes | Stripe credits (\$0.01/call) | **Not** billed via Stripe — you pay gas on-chain |
| Billing — Lit Actions | Stripe credits (\$0.01/sec) | Stripe credits (\$0.01/sec) — identical to API mode |
In the SDK and contracts, ChainSecured mode is the `sovereign` mode
(`mode: 'sovereign'`); earlier material called it *self-sovereign*. Same
thing — wallet ownership of the account on Base, with no required server
round-trip for admin writes.
### How billing splits in ChainSecured mode
This is the one place the two modes diverge on billing, and it surprises
people: **admin writes and Lit Action execution are billed on different
rails.**
* **Admin writes** (create group, add action, mint usage key, ...) in
ChainSecured mode are wallet-signed transactions you submit directly to
the `AccountConfig` contract. They never pass through the server's
metered HTTP endpoint, so they never hit the Stripe credit guard. You
pay the **Base gas** for each write from your connected wallet, and
**nothing is charged to Stripe** — there is no `$0.01` management charge.
* **Lit Action execution** is unchanged. Runs are still metered at
`$0.01/second` against your Stripe credit balance, exactly as in API
mode, because execution goes through the server regardless of who owns
the account.
In API mode both halves are billed to Stripe — admin writes at `$0.01`
each (the server relays the tx and covers gas) and execution at
`$0.01/second`. Converting to ChainSecured moves only the admin-write half
off Stripe and onto your wallet's gas; the execution half is untouched.
**For ops and support:** after a customer converts to ChainSecured, their
Stripe activity drops to *Lit Action execution only* — admin writes stop
appearing in Stripe entirely. This is expected, not "billing broken." A
freshly-converted account that only does admin writes (and no executions)
can legitimately show **zero** Stripe charges. The Stripe credit balance
itself is preserved across conversion (see
[Converting an API account to ChainSecured](#converting-an-api-account-to-chainsecured)).
To confirm an account is sovereign, take the Stripe customer's
`metadata.wallet_address` and read the account's on-chain `managed` flag
(`managed = false` ⇒ ChainSecured). A recommended ops convenience is to
tag such customers in the Stripe dashboard (e.g. a `mode: sovereign`
metadata field) so they're filterable without an on-chain lookup.
## 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.
ChainSecured-mode users instead see **Change Ownership**, which transfers
the account to a different admin wallet (see
[Transferring ownership of a ChainSecured account](#transferring-ownership-of-a-chainsecured-account)).
## 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. Both EOAs and
smart-contract wallets (a Gnosis Safe, a ZeroDev/Kernel account, etc.)
are supported. You pass the **same** request shape either way — the
server first attempts standard ECDSA recovery, and if the signature
doesn't recover to the claimed address it treats that address as a
contract and verifies the signature via an on-chain ERC-1271
`isValidSignature(digest, signature)` call on the chain reported by
`GET /get_node_chain_config` (Base). The contract wallet must already
be **deployed** on that chain — counterfactual (ERC-6492) wallets are
not supported. The dashboard prompts a chain switch if your wallet
isn't on that chain.
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.
## Transferring ownership of a ChainSecured account
Once an account is ChainSecured, you can hand it to a different admin
wallet — for example, to rotate a compromised key, move control to a new
team wallet, or migrate from an EOA to a fresh address. The transfer
reassigns the on-chain admin in a single transaction while preserving the
master `apiKeyHash` and the billing wallet, so groups, PKPs, action
metadata, usage API keys, and the Stripe credit balance all stay attached
to the same account.
This is the *ChainSecured-to-ChainSecured* counterpart of conversion. It
does **not** change the `managed` flag — the account is unmanaged before
and after — it only swaps which wallet holds admin authority.
### How it differs from conversion
Conversion is api\_payer-relayed because a managed account has no on-chain
admin to sign with. A ChainSecured account already has one, so the
transfer is signed **directly by the current admin wallet** — there is no
server endpoint, no EIP-712 typed-data envelope, and no api\_payer
involvement.
| Dimension | Convert to ChainSecured | Transfer ChainSecured ownership |
| ---------------- | ------------------------------------------------ | -------------------------------------------------- |
| Starting mode | API (managed) | ChainSecured (unmanaged) |
| Who signs | api\_payer relays on your behalf | The current admin wallet, directly |
| Server endpoint | `POST /core/v1/convert_to_chain_secured_account` | None — direct contract call |
| Signature scheme | EIP-712 ownership proof (`ConvertAccount`) | Plain transaction signature from the current admin |
| `managed` flag | `true` → `false` | Stays `false` |
| Requires API key | Yes (your existing master key) | No — wallet signature is the only credential |
### What's preserved
* The master on-chain `apiKeyHash` (so all child resources stay attached).
* The **billing wallet** and Stripe credit balance.
* **Groups**, **PKPs**, **action metadata**, and **usage API keys** —
they continue to authorize Lit Action execution exactly as before.
### What changes
* `account.adminWalletAddress` becomes the new wallet.
* Admin write authority moves entirely to the new wallet. The previous
admin wallet can no longer authorize writes on this account, effective
immediately on confirmation.
* A new lookup entry — `uint256(keccak256(abi.encodePacked(newAdminWalletAddress)))`
→ master `apiKeyHash` — is registered so the new wallet resolves to the
account on login. (In ethers this is
`ethers.solidityPackedKeccak256(['address'], [newAdminWalletAddress])`.)
### The contract function
```solidity theme={null}
WritesFacet.transferChainSecuredAccountOwnership(
uint256 apiKeyHash,
address newAdminWalletAddress
)
```
`apiKeyHash` may be the master hash or any hash that resolves to it
(e.g. `uint256(keccak256(abi.encodePacked(currentAdminWalletAddress)))`
for an account that has already been transferred once); the contract maps
it to the master via `allApiKeyHashesToMaster`. On success it emits
`ChainSecuredAccountOwnershipTransferred(masterApiKeyHash, previousAdminWalletAddress, newAdminWalletAddress)`
— note the first topic is the **resolved master** hash, not the
`apiKeyHash` argument you passed in (they differ whenever you pass an
alias).
The call reverts if:
* `newAdminWalletAddress` is the zero address.
* No account resolves from `apiKeyHash` (`AccountDoesNotExist`).
* The account is managed (`InvalidRequest` — use
`convertToChainSecuredAccount` instead).
* The caller is not the current admin wallet (`NoAccountAccess`). The
api\_payer has **no** authority here.
* `newAdminWalletAddress` equals the current admin.
* `newAdminWalletAddress` already owns an account
(`AccountAlreadyExists`).
The previous admin's lookup entry is intentionally left in place, so a
wallet that has *ever* been admin of any account can't become the target
of a transfer — even the wallet you just transferred away from. Plan
rotations forward to fresh addresses; you cannot transfer ownership back.
### Step-by-step (dashboard)
1. **Sign in to the dashboard in ChainSecured mode** by connecting the
current admin wallet.
2. Open the account dropdown and click **Change Ownership** (this item is
hidden in API mode).
3. **Enter the new admin wallet address** in the prompt. It must be a
valid Ethereum address and must not already own an account.
4. **Confirm the irreversible-transfer prompt.** Your connected wallet
loses admin access the moment the transaction confirms and the new
wallet becomes the sole admin.
5. **Sign the transaction** with your current wallet when prompted. The
dashboard shows a transaction preview before the wallet signs and a
status banner while it confirms.
6. On success the dashboard signs you out and reloads. Log back in by
connecting the **new** admin wallet to continue managing the account.
### Step-by-step (SDK)
```javascript theme={null}
import { LitNodeSimpleApiClient } from './core_sdk.js';
import { ethers } from 'ethers';
// Connect the CURRENT admin wallet in sovereign mode.
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const client = new LitNodeSimpleApiClient({
baseUrl: 'https://api.chipotle.litprotocol.com',
mode: 'sovereign',
rpcUrl: 'https://mainnet.base.org',
contractAddress: '0xYourAccountConfigDiamond',
signer,
// Identity hash for the connected wallet. Required for ChainSecured
// sessions — without it the SDK hashes an empty apiKey string and the
// transfer reverts with AccountDoesNotExist. The dashboard sets this
// automatically at login.
adminHashOverride: ethers.solidityPackedKeccak256(['address'], [address]),
});
const res = await client.transferChainSecuredAccountOwnership({
newAdminWalletAddress: '0xNewAdminWallet',
});
console.log('Previous admin:', res.previous_admin);
console.log('New admin:', res.new_admin);
console.log('Tx hash:', res.transaction_hash);
```
`transferChainSecuredAccountOwnership` is **sovereign-mode only** and
throws if called from an API-mode client. It validates and checksums
`newAdminWalletAddress`, then resolves the current admin's `apiKeyHash`
from the client's `adminHashOverride` — so a ChainSecured session must
have that set to `keccak256(abi.encodePacked(address))` (the dashboard
does this at login; set it via the constructor as above when driving the
SDK directly, otherwise the call reverts with `AccountDoesNotExist`).
After it resolves, the connected signer is no longer authorized for the
account — reconnect with the new wallet to keep managing it.
### Things to verify after a transfer
* An admin write signed by the **new** wallet succeeds.
* An admin write signed by the **previous** wallet fails — the contract
rejects it now that it is no longer the admin.
* `accountExistsByHash(ethers.solidityPackedKeccak256(['address'], [newAdminWalletAddress]))`
returns `true` from the new wallet's context (the on-chain key is
`uint256(keccak256(abi.encodePacked(newAdminWalletAddress)))`).
* All groups, PKPs, action CIDs, and existing usage API keys are visible
under the new wallet session, and a Lit Action run with one of those
usage keys still succeeds.
* The Stripe credit balance is unchanged.
## 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://docs.dev.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` and `BASE=https://api.chipotle.litprotocol.com` (the hosted production API). 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](#2-add-funds)
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"
```
The very first call takes \~15 seconds — account creation waits for an on-chain
transaction on Base. Every other call below returns in well under a second.
### 2. Add funds
Lit Action execution and write/metered management calls (including the steps
below) consume credits; read-only calls are free. Add funds with a credit card
in the [Dashboard](/management/pricing#paying-with-a-credit-card-via-stripe),
with [crypto](/management/crypto) (ETH, USDC, SOL and more) or
[LITKEY](/management/litkey), or programmatically via the
[billing API](#billing). Without credits the steps below return
`402 Payment Required` — see [Errors](/management/errors).
### 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. (Every account also starts with wallet id `0` — the Account Master Wallet created at signup.)
```javascript theme={null}
const res = await client.createWallet(accountApiKey);
console.log('Wallet address:', res.wallet_address);
```
```bash theme={null}
curl -s -X POST "https://api.chipotle.litprotocol.com/core/v1/create_wallet" \
-H "X-Api-Key: KEY"
```
`GET /create_wallet` also works but is deprecated — minting is a metered write,
and GETs get replayed by prefetchers and retrying proxies. Use POST.
### 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}'
```
**Newly-granted permissions are eventually consistent — verify before you depend on them.** After `add_usage_api_key` (or `add_action_to_group` / `add_pkp_to_group`) there's a short, variable delay before the grant works on `/lit_action`, so the first call right after minting a key can fail. Don't sleep a fixed amount (it flakes or over-waits) — poll the real path (run the actual action with the actual key) until it succeeds, then proceed. A small retry on network errors and `5xx` helps too. Worked version: `waitForUsageKeyReady` + retry wrapper in [`examples/action-bound-wallet`](https://github.com/LIT-Protocol/chipotle/tree/main/examples/action-bound-wallet).
### 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://docs.dev.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) |
A freshly-created usage key's group permissions are eventually consistent — the first `/lit_action` call right after `add_usage_api_key` can fail for a beat while the grant propagates. Don't sleep a fixed amount; poll the real execution path until it succeeds. See [Verify the real path before you depend on it](/management/api_direct#7-run-lit-action).
***
### 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://docs.dev.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';
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://docs.dev.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
# Errors
Source: https://docs.dev.litprotocol.com/management/errors
Every error status the API returns, what causes it, and how to fix it.
Every error response from the API is JSON. There are two shapes:
**Catcher errors** (auth, billing, routing, body parsing) return a structured object:
```json theme={null}
{
"error": "payment_required",
"message": "Insufficient credits: this call needs $0.01 but your balance is $0.00.",
"fix": "Add funds (minimum $5.00, card or crypto) in the dashboard at https://dashboard.chipotle.litprotocol.com/dapps/dashboard/ or via POST /core/v1/billing/create_payment_intent. Check your balance with GET /core/v1/billing/balance.",
"docs_url": "https://developer.litprotocol.com/management/pricing"
}
```
**Endpoint-specific failures** (a contract revert, an execution error) return a JSON string with the error message and the matching HTTP status:
```json theme={null}
"Permission denied"
```
Robust clients should branch on the HTTP status code first and treat the body as
diagnostic detail.
## Status codes
| Status | `error` | What it means | What to do |
| --------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400 Bad Request` | `bad_request` | The body is malformed JSON or a parameter has the wrong shape. | Compare your request against the [OpenAPI spec](https://api.chipotle.litprotocol.com/core/v1/swagger-ui). |
| `401 Unauthorized` | `unauthorized` | No API key was sent, **or the key does not resolve to any account** (typo'd, revoked, or never created). | Send the key via `X-Api-Key` or `Authorization: Bearer`. Verify it with `GET /core/v1/account_exists`. |
| `402 Payment Required` | `payment_required` | Your account exists but its credit balance cannot cover this operation. The `message` includes the required amount and your balance. | [Add funds](/management/pricing) — card or [crypto](/management/crypto) — or `POST /core/v1/billing/create_payment_intent`. Check balance with `GET /core/v1/billing/balance`. |
| `403 Forbidden` | `forbidden` | The key is valid and funded but not permitted to do this — usually a usage key acting outside its granted groups. | Inspect scopes with `GET /core/v1/list_api_keys`, or use the account master key. See [API Keys](/management/api_keys). |
| `404 Not Found` | `not_found` | No such endpoint or resource. | Browse `/core/v1/swagger-ui`. |
| `422 Unprocessable Entity` | `unprocessable_entity` | The body parsed as JSON but doesn't match the endpoint's schema. | Compare field names and types against the OpenAPI spec. |
| `429 Too Many Requests` | `too_many_requests` | The node is shedding load (CPU pressure). | Retry with backoff. |
| `500 Internal Server Error` | `internal_error` | Something failed on our side. | Retry; report it if persistent. |
| `503 Service Unavailable` | `service_unavailable` | A dependency (billing, chain RPC, or the actions runtime) is temporarily down. Nothing was charged. | Retry in a few seconds. |
## Billing guarantees on errors
* **Failed requests are not charged.** The flat \$0.01 management charge settles
only after the operation succeeds. A request rejected for a bad body (400/422),
missing permission (403), or a server error (5xx) costs nothing.
* **An invalid key is a 401, not a 402.** You will only ever see
`402 Payment Required` for an account that actually exists and is short on
credits.
* **Billing outages are a 503, not a 402.** If Stripe or the chain RPC is
unreachable, the API tells you to retry — it does not tell you to pay.
## Common sequences
**"I created an account but every call says 402."**
New accounts start with no credits unless the node grants starter credits.
Running Lit Actions and write/metered management calls (creating wallets,
groups, usage keys) consume credits; read-only calls (`list_*`, `account_exists`,
`billing/balance`) are free. [Add funds](/management/pricing) to proceed.
**"My key worked yesterday and now I get 401."**
The key was removed (`remove_usage_api_key`) or you're sending a truncated
value. Keys are shown once at creation; verify with `GET /core/v1/account_exists`
and mint a new usage key from your account key if needed.
**"The error body isn't JSON."**
It is, as of API version `v1.2`. If you see an HTML error page you are talking
to something other than the API (a proxy, a wrong URL) — check the host and
path.
# LITKEY
Source: https://docs.dev.litprotocol.com/management/litkey
The LITKEY token — pay for Lit Protocol services on-chain and get a 25% discount over credit card.
## Overview
**LITKEY** is the native payment token for Lit Protocol services. You can use it to buy API credits directly from your wallet — no Stripe, no card, no fiat conversion.
Paying with LITKEY gets you a **25% discount** on API credits compared to paying with a credit card.
Swap ETH for LITKEY on Aerodrome (Base mainnet).
***
## Why pay with LITKEY?
* **25% discount** vs. credit card pricing. Every dollar of LITKEY credits 1.33× the dashboard rate.
* **No card required.** Pay from any wallet that holds LITKEY.
* **On-chain settlement.** Payments are verifiable on Base.
***
## Where LITKEY lives
LITKEY is live on a number of chains, but **Base** is the primary payment chain and where most liquidity sits. The canonical token on Base is:
```
0xf732a566121fa6362e9e0fbdd6d66e5c8c925e49
```
If you hold LITKEY on another chain, bridge it to Base before paying:
Move LITKEY between supported chains via Hyperlane Nexus.
***
## How to pay with LITKEY
1. Acquire LITKEY on Base (see **Get LITKEY** above), or bridge from another chain via Hyperlane Nexus.
2. From the [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/), choose **Pay with LITKEY** when adding funds.
3. Confirm the account to credit, approve the exact LITKEY amount, and submit the payment transaction.
4. Credits are applied to your account once the on-chain payment is confirmed.
For credit-card and Stripe-crypto flows, see [Pricing](/management/pricing) and [Crypto Payments](/management/crypto).
# Pricing
Source: https://docs.dev.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.
### Paying with LITKEY (25% discount)
Paying with the **LITKEY** token gets you a **25% discount** vs. credit card pricing. LITKEY is paid directly on-chain (Base mainnet) — no Stripe involved. See the [LITKEY](/management/litkey) guide for where to acquire it and how to pay.
***
## Enterprise Pricing
Need higher volume, custom terms, or dedicated support? Reach out for enterprise pricing by filling out [this form](https://docs.google.com/forms/d/e/1FAIpQLScBVsg-NhdMIC1H1mozh2zaVX0V4WtmEPSPrtmqVtnj_3qqNw/viewform) and our team will get in touch.
***
## 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.
In **ChainSecured (sovereign) mode** there is no account API key — billing is tied to the account's **billing wallet** instead. This wallet is fixed at account creation and preserved across conversion to ChainSecured and across ownership transfers, so your Stripe credit balance follows the account even as the admin wallet rotates.
***
## Billing in ChainSecured (sovereign) mode
[ChainSecured mode](/management/account_modes) splits billing across two rails, and the split is worth calling out explicitly:
| What you do | API mode | ChainSecured (sovereign) mode |
| ----------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Admin write (create group, add action, mint usage key, ...) | \$0.01 credit charged via Stripe; the server relays the tx and covers gas | **Not charged to Stripe.** You sign and submit the tx yourself and pay **Base gas** from your connected wallet |
| Lit Action execution | \$0.01/second via Stripe | \$0.01/second via Stripe — **identical to API mode** |
In other words, converting to ChainSecured moves only the **admin-write** half of billing off Stripe (onto your wallet's gas). Lit Action **execution** is metered against your Stripe credit balance in both modes, because execution always runs through the server. A ChainSecured account that performs admin writes but no executions can legitimately show **zero** Stripe charges — admin-write gas is paid on-chain and never reaches Stripe.
This is the expected behavior, not a billing fault: there is no `$0.01` management charge for sovereign admin writes because those writes never pass through the server's metered endpoint.
# Quick Start
Source: https://docs.dev.litprotocol.com/quickstart
Go from zero to a running Lit Action in a few minutes using the Lit Dashboard or the REST API.
This guide walks you through creating an account, funding it, and running your first Lit Action. You can do everything from the [**Dashboard**](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/) (web GUI) or directly against the [**REST API**](https://api.chipotle.litprotocol.com/).
## Zero to Lit Action
1. [Create an account via the Dashboard](/management/dashboard#1-request-a-new-account-or-log-in), note down your API key. Account creation takes \~15 seconds — it registers your account on-chain (Base).
2. [Add funds](/management/pricing#paying-with-a-credit-card-via-stripe) — click **Add Funds** in the Dashboard (minimum \$5.00) and pay with a credit card, [crypto](/management/crypto) (ETH, USDC, SOL and more), or [LITKEY](/management/litkey). 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. Unfunded metered calls return `402 Payment Required` — see [Errors](/management/errors).
3. Add a usage API key — set its permissions by clicking **All Options**
4. Run a Lit Action
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)
## Using the Dashboard
The [Dashboard](https://dashboard.chipotle.litprotocol.com/dapps/dashboard/) is a web management GUI for Lit. It supports light/dark themes and provides simple tools for managing accounts, keys, wallets, and actions.
**Recommended workflow:**
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 step-by-step instructions with screenshots, see [Using the Dashboard](/management/dashboard).
## Using the API directly
The same workflows are available via the REST API under `/core/v1/`. All authenticated endpoints expect the API key in a header (`X-Api-Key` or `Authorization: Bearer`).
**Workflow:**
1. [New account or verify account (login)](/management/api_direct#1-new-account-or-verify-account-login)
2. [Add funds](/management/api_direct#2-add-funds) — card, crypto, or the billing API
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, you can call the lit-action endpoint with your usage key every time you, your dApp, or a cron job needs to execute an action:
1. Call the API with your usage key, action code (or IPFS CID), and any parameters you need.
## Next steps
* [Lit Actions Overview](/lit-actions/index) — what they are and how they run
* [Examples](/lit-actions/examples) — signing, encryption, HTTP fetching, contract calls
* [Architecture](/architecture/index) — the TEE / on-chain / IPFS layers
* [Chain Secured](/architecture/chain-secured) — why your keys' authority lives on-chain, and how an attested TEE enforces it
* [OpenAPI Spec](https://api.chipotle.litprotocol.com/core/v1/openapi.json) / [Swagger UI](https://api.chipotle.litprotocol.com/core/v1/swagger-ui)