Skip to main content
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

DimensionAPI modeChainSecured mode
Account ownerWallet derived from a server-generated random secretYour wallet (EOA / Safe / contract) on Base
Account-level credentialBase64 API key (X-Api-Key header)None — wallet signature is the credential
Admin write pathHTTP POST /core/v1/... → server submits the txDirect contract call from your wallet
Gas for admin writesServer pays (covered by the per-call credit charge)You pay gas from the connected wallet
RecoveryRetain/back up the API key; if lost, create a new accountWhatever your wallet supports (seed, Safe signers)
On-chain managed flagtruefalse
Onboarding speedFastest — paste an email, get a keyRequires a funded wallet on Base
Trust modelYou trust Lit’s server to relay your intentTrust-minimized — every admin write is on-chain
AuditabilityServer logs + on-chain eventsOn-chain events only; every change is wallet-signed
Dashboard surfaceSame management UISame management UI; writes prompt the wallet
Lit Action executionUsage API key in X-Api-KeyUsage API key in X-Api-Key (minted from contract)
BillingStripe credits on the accountStripe credits on the account (same flow)
ChainSecured mode is referred to as self-sovereign in some internal material. They are the same thing — wallet ownership of the account on Base, with no required server round-trip for admin writes.

When to pick which

Pick API mode if:

  • You want to ship today and don’t want to manage gas, an RPC, or a wallet popup in your admin tooling.
  • Your client is a server, a cron job, or a CI pipeline that needs a single shared credential.
  • You’re prototyping or iterating quickly — onboarding is fast, usage API keys are rotatable (mint and revoke at will), and the dashboard reflects every change immediately.
  • You don’t have a strong requirement that every configuration change be visible on-chain.
API mode is the default and is shown as Recommended in the dashboard’s login screen.

Pick ChainSecured mode if:

  • You want self-custody of the account: no third party (including Lit) can unilaterally create groups, add actions, or mint usage keys on your behalf.
  • A multisig (Safe) or DAO governs configuration changes — every action upgrade, every PKP added to a group, becomes a Safe proposal that signers can review.
  • You want a fully on-chain audit trail of every admin operation, signed by your governance wallet.
  • You’re integrating with a wallet-native dApp where the user’s connected wallet is already the natural source of authority.
ChainSecured accounts have managed = false on-chain and reject any admin write that does not originate from the registered admin wallet.

How the wiring differs

API mode (mode: 'api', default)

Calls go over HTTP with your account API key in the header. The Core SDK default constructor is API mode:
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 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.
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:
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).

Converting an API account to ChainSecured

Conversion flips a managed account to unmanaged in a single on-chain transaction. The account’s on-chain apiKeyHash is preserved, so groups, PKPs, action metadata, usage API keys, and the Stripe credit balance all stay attached to the same account record. Only the admin wallet address and the managed flag change. The contract function is WritesFacet.convertToChainSecuredAccount(uint256 apiKeyHash, address newAdminWalletAddress), which is apiPayerOrOwner-gated. End users don’t call it directly — they call POST /core/v1/convert_to_chain_secured_account with their existing API key plus a wallet-signed proof of ownership of the new admin address; the server’s api_payer signs the on-chain conversion on their behalf.

What’s preserved

  • The on-chain apiKeyHash (so all child resources stay attached).
  • Groups (permitted PKP IDs and CID hashes).
  • PKPs (derivation paths, names, descriptions).
  • Action metadata (registered IPFS CID names and descriptions).
  • Usage API keys — they continue to authorize Lit Action execution exactly as before.
  • Stripe credit balance — the wallet_cache entry is invalidated on conversion so billing routes to the new admin wallet’s customer record immediately.

What changes

  • account.adminWalletAddress becomes the new wallet you signed with.
  • account.managed flips from true to false.
  • Admin write authority moves entirely to the connected wallet. The original master API key can no longer authorize writes on this account (the contract rejects api_payer relays for unmanaged accounts).
  • Read endpoints that key off apiKeyHash continue to resolve to the same account.

Step-by-step (dashboard)

  1. Sign in to the dashboard in API mode with your existing master API key.
  2. Open the account dropdown and click Convert to ChainSecured. Confirm the irreversible-action prompt.
  3. Connect the wallet that will become the new admin. EOA only — the server verifies the EIP-712 signature via plain ECDSA recover() and does not yet validate ERC-1271 contract-account signatures, so Safes and other contract wallets are not supported in this flow today. The dashboard prompts a chain switch if your wallet isn’t on the chain reported by GET /get_node_chain_config.
  4. Sign the EIP-712 ownership-transfer typed data. The dashboard composes a typed-data envelope with primaryType: "ConvertAccount" — wallet UIs surface it as a labelled struct (address, issuedAt) under the Lit ChainSecured domain rather than a free-form message. The full canonical envelope is:
    {
      "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": "<chain id>" },
      "message": { "address":  "<new admin address>", "issuedAt": "<unix seconds>" }
    }
    
    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 POSTs /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)

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.
DimensionConvert to ChainSecuredTransfer ChainSecured ownership
Starting modeAPI (managed)ChainSecured (unmanaged)
Who signsapi_payer relays on your behalfThe current admin wallet, directly
Server endpointPOST /core/v1/convert_to_chain_secured_accountNone — direct contract call
Signature schemeEIP-712 ownership proof (ConvertAccount)Plain transaction signature from the current admin
managed flagtruefalseStays false
Requires API keyYes (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

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)

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