Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.dev.litprotocol.com/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 using the Decrypt action below.

3. Decrypt a Secret

Decrypt a ciphertext that was previously produced by Lit.Actions.Encrypt using the same PKP. Only an action that is permitted to use the PKP (enforced on-chain) can decrypt it.
// 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.”
// 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. Same multi-source idea as example 9, but the parallel sources are AI models instead of price feeds — and 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. Same content-addressed trust property as the compliance gate: 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.”
// 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.