Skip to main content

Documentation Index

Fetch the complete documentation index at: https://litprotocol-docs-standalone-lit-action-examples.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

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.