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

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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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 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_calls 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. 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.
// 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:
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/ 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.
// 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/ 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 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.
// 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/ 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 and Hostname-Pinned RPC Trust Anchors.
// 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/ 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). Edit the policy and the signer address changes, so the vault stops trusting the modified action.
// 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/ 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 from §8), then signs the state update with its CID-derived identity (see Action-Identity Signing). 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.
// 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/ in the repo. It builds on the sanctions gate (§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() — 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.
// 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/ 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.
// 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/ in the repo. It uses Neon 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 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.
// 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/ 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 (jsDelivr).
// 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/ in the repo.